diff --git a/docs/FEATURE_PRIORITIZATION.md b/docs/FEATURE_PRIORITIZATION.md index aa52e770..c9bd0214 100644 --- a/docs/FEATURE_PRIORITIZATION.md +++ b/docs/FEATURE_PRIORITIZATION.md @@ -26,7 +26,7 @@ | 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth | | 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth | | 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth | -| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts | +| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts | | 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth | | 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) | | 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) | 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 b9842f15..494a354c 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}`, @@ -84,6 +87,7 @@ export const API_PATHS = { create: "team-members", detail: "team-members", invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`, + myPermissions: "team-members/me/permissions", }, teamMemberInvite: { resolve: "team-member-invites/resolve", diff --git a/src/app/(protected)/_layout.tsx b/src/app/(protected)/_layout.tsx index a5826b19..bffc9e3d 100644 --- a/src/app/(protected)/_layout.tsx +++ b/src/app/(protected)/_layout.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route"; import { AppSidebar } from "@/components/layout/app-sidebar"; import { getSidebarWidth } from "@/config/menu"; import { AppTopbar } from "@/components/layout/app-topbar"; +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; export default function AdminShellLayout() { @@ -40,6 +41,7 @@ export default function AdminShellLayout() { sx={{ width: `calc(100% - ${sidebarWidth}px)` }} > + 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..b835e35a --- /dev/null +++ b/src/app/(protected)/completion-docs/_components/use-completion-template-permissions.ts @@ -0,0 +1,17 @@ +import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions"; + +export interface CompletionTemplatePermissions { + canCreate: boolean; + canEdit: boolean; + canDelete: boolean; +} + +/** Template actions follow the signed-in user's effective permissions, never their role alone. */ +export function useCompletionTemplatePermissions(): CompletionTemplatePermissions { + const permissions = usePermissions(); + return { + canCreate: hasPermission(permissions, "createCompletionDocTemplates"), + canEdit: hasPermission(permissions, "editCompletionDocTemplates"), + canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"), + }; +} 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/add-site-dialog.tsx b/src/app/(protected)/locations/_components/add-site-dialog.tsx new file mode 100644 index 00000000..6c73ab0e --- /dev/null +++ b/src/app/(protected)/locations/_components/add-site-dialog.tsx @@ -0,0 +1,94 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from "@mui/material"; +import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop"; +import { + locationDefaultValues, + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; +import { + DUPLICATE_SITE_CODE_MESSAGE, + isDuplicateSiteCodeError, +} from "@/domain/locations/utils/site-code-conflict"; +import { SiteFormFields } from "./site-form-fields"; + +interface AddSiteDialogProps { + open: boolean; + onClose: () => void; +} + +/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */ +export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) { + const createLocation = useCreateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: locationDefaultValues, + }); + + const close = () => { + form.reset(locationDefaultValues); + createLocation.reset(); + onClose(); + }; + + const onSubmit = (values: LocationFormValues) => { + createLocation.mutate(values, { + onSuccess: close, + onError: (error) => { + if (isDuplicateSiteCodeError(error)) { + form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE }); + } + }, + }); + }; + + return ( + { + if (!createLocation.isPending) { + close(); + } + }} + fullWidth + maxWidth="sm" + aria-labelledby="add-site-title" + slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }} + > + + + Add Site + + Create a new client site record. + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/client-select.tsx b/src/app/(protected)/locations/_components/client-select.tsx new file mode 100644 index 00000000..514811ba --- /dev/null +++ b/src/app/(protected)/locations/_components/client-select.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import { Controller, type Control, type FieldError } from "react-hook-form"; +import { Autocomplete, TextField } from "@mui/material"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { useDebounce } from "@/hooks/use-debounce"; + +type ClientOption = Pick; + +interface ClientSelectProps { + control: Control; + error?: FieldError; + /** Stored client, shown even when it is not in the first page of search results. */ + initialClient?: ClientOption; +} + +/** Searchable client (account) picker bound to `accountId`. */ +export function ClientSelect({ control, error, initialClient }: ClientSelectProps) { + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 }); + + const options = useMemo(() => { + const items: ClientOption[] = data?.items ?? []; + const hasInitial = + initialClient !== undefined && + items.some((item) => String(item.id) === String(initialClient.id)); + return initialClient === undefined || hasInitial ? items : [initialClient, ...items]; + }, [data?.items, initialClient]); + + return ( + ( + + options={options} + loading={isLoading} + getOptionLabel={(option) => option.name || String(option.id)} + isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} + value={options.find((option) => String(option.id) === field.value) ?? null} + onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} + onBlur={field.onBlur} + onInputChange={(_event, value, reason) => { + if (reason === "input") { + setSearch(value); + } + }} + renderInput={(params) => ( + + )} + /> + )} + /> + ); +} diff --git a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx index 9053cbe4..ed3122f0 100644 --- a/src/app/(protected)/locations/_components/location-contacts-field-array.tsx +++ b/src/app/(protected)/locations/_components/location-contacts-field-array.tsx @@ -202,7 +202,7 @@ export function LocationContactsFieldArray({ - Contacts + Points of contact 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 0b02c348..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 Location - - - Are you sure you want to delete "{target?.name}"? This action cannot be undone. - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form-fields.tsx b/src/app/(protected)/locations/_components/location-form-fields.tsx deleted file mode 100644 index 39f22296..00000000 --- a/src/app/(protected)/locations/_components/location-form-fields.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form"; -import { - Autocomplete, - FormControl, - InputLabel, - MenuItem, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import type { AccountListItem } from "@/domain/accounts/types/account"; -import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; -import { LocationContactsFieldArray } from "./location-contacts-field-array"; -import { StateSelect } from "./state-select"; - -interface SharedFieldsProps { - control: Control; - errors: FieldErrors; - register: UseFormRegister; -} - -interface LocationDetailsFieldsProps extends SharedFieldsProps { - accountOptions: AccountListItem[]; - isLoadingAccounts: boolean; - onAccountSearchChange: (value: string) => void; -} - -export function LocationDetailsFields({ - accountOptions, - control, - errors, - isLoadingAccounts, - onAccountSearchChange, - register, -}: LocationDetailsFieldsProps) { - return ( - - - Location Information - - - - ( - option.name || String(option.id)} - isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)} - value={accountOptions.find((account) => String(account.id) === field.value) ?? null} - onChange={(_event, option) => field.onChange(option ? String(option.id) : "")} - onInputChange={(_event, value, reason) => { - if (reason === "input") { - onAccountSearchChange(value); - } - }} - renderInput={(params) => ( - - )} - className="flex-1" - /> - )} - /> - - - - - - - - - ); -} - -export function LocationContactFields({ control, errors, register }: SharedFieldsProps) { - return ( - - - Contact Information - - - - - ( - - Status - - - )} - /> - - - ); -} diff --git a/src/app/(protected)/locations/_components/location-form.tsx b/src/app/(protected)/locations/_components/location-form.tsx deleted file mode 100644 index b410d43d..00000000 --- a/src/app/(protected)/locations/_components/location-form.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useParams } from "react-router"; -import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material"; -import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list"; -import { normalizeStateCode } from "@/data/areas"; -import { - locationDefaultValues, - locationSchema, - type LocationFormValues, -} from "@/domain/locations/schemas/location-schema"; -import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location"; -import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; -import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; -import { useDebounce } from "@/hooks/use-debounce"; -import { LocationContactFields, LocationDetailsFields } from "./location-form-fields"; - -export default function LocationFormPage() { - const { id } = useParams(); - const navigate = useNavigate(); - const isEdit = Boolean(id); - - const [accountSearch, setAccountSearch] = useState(""); - const debouncedAccountSearch = useDebounce(accountSearch, 300); - - const { data: location, isLoading, error } = useLocationDetail(id); - const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({ - search: debouncedAccountSearch, - page: 1, - pageSize: 50, - }); - const createLocation = useCreateLocation(); - const updateLocation = useUpdateLocation(); - - const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]); - - const { - register, - handleSubmit, - reset, - control, - formState: { errors }, - } = useForm({ - resolver: zodResolver(locationSchema), - defaultValues: locationDefaultValues, - }); - - useEffect(() => { - if (location && isEdit) { - reset({ - name: location.name ?? "", - accountId: location.accountId != null ? String(location.accountId) : "", - address: location.address ?? "", - city: location.city ?? "", - state: normalizeStateCode(location.state) ?? "", - zipCode: location.zipCode ?? "", - contacts: - location.contacts && location.contacts.length > 0 - ? location.contacts - : [{ name: location.contact ?? "", phone: location.phone ?? "" }], - contactEmail: location.contactEmail ?? "", - status: (location.status as LocationFormValues["status"]) || "Active", - }); - } - }, [location, isEdit, reset]); - - const isSaving = createLocation.isPending || updateLocation.isPending; - - const onSubmit = (values: LocationFormValues) => { - if (isEdit && id) { - updateLocation.mutate({ id, values }); - return; - } - createLocation.mutate(values); - }; - - if (isEdit && isLoading) { - return ( - - - - ); - } - - return ( - - - - - - {isEdit ? "Edit Location" : "Create a Location"} - - - - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load location"} - - )} - - - - - - - - - - - - - - ); -} diff --git a/src/app/(protected)/locations/_components/locations-table.tsx b/src/app/(protected)/locations/_components/locations-table.tsx index bfd36118..a7332a76 100644 --- a/src/app/(protected)/locations/_components/locations-table.tsx +++ b/src/app/(protected)/locations/_components/locations-table.tsx @@ -1,9 +1,6 @@ -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; -import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Button, CircularProgress, - IconButton, Paper, Table, TableBody, @@ -11,7 +8,6 @@ import { TableContainer, TableHead, TableRow, - Tooltip, Typography, } from "@mui/material"; import type { LocationListItem } from "@/domain/locations/types/location"; @@ -20,84 +16,82 @@ import { StateAreaCell } from "./state-area-cell"; interface LocationsTableProps { isLoading: boolean; - onDelete: (row: LocationListItem) => void; - onEdit: (row: LocationListItem) => void; + onOpen: (row: LocationListItem) => void; rows: LocationListItem[]; + selectedId?: string | null; } -export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) { +const COLUMN_COUNT = 7; + +export function LocationsTable({ + isLoading, + onOpen, + rows, + selectedId = null, +}: LocationsTableProps) { return ( - Location Name - Address + Site Code + Client + Street Address City State - Phone - Contact - Status - - Actions - + POC Name + POC Phone - {isLoading ? ( + {isLoading && ( - + - ) : rows.length === 0 ? ( + )} + {!isLoading && rows.length === 0 && ( - - No locations found + + No sites found - ) : ( + )} + {!isLoading && rows.map((row) => ( - + onOpen(row)} + className="cursor-pointer" + > + {row.clientName || "—"} {row.address || "—"} {row.city || "—"} - {row.phone || "—"} - {row.status || "—"} - - - onEdit(row)}> - - - - - onDelete(row)} - > - - - - + {row.phone || "—"} - )) - )} + ))}
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-edit.tsx b/src/app/(protected)/locations/_components/site-detail-edit.tsx new file mode 100644 index 00000000..00355440 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-edit.tsx @@ -0,0 +1,68 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Box, Button, Stack } from "@mui/material"; +import { + locationSchema, + type LocationFormValues, +} from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; +import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location"; +import { SiteFormFields } from "./site-form-fields"; +import { toSiteFormValues } from "./site-form-values"; + +interface SiteDetailEditProps { + siteId: string; + location: Location; + /** Back to view mode: after Cancel (no API call) or after a successful save. */ + onDone: () => void; +} + +/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */ +export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) { + const updateLocation = useUpdateLocation(); + const form = useForm({ + resolver: zodResolver(locationSchema), + defaultValues: toSiteFormValues(location), + mode: "onChange", + }); + const { isDirty, isValid } = form.formState; + const initialClient = + location.accountId != null && location.clientName + ? { id: location.accountId, name: location.clientName } + : undefined; + + const onSubmit = (values: LocationFormValues) => { + updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone }); + }; + + return ( + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-panel.tsx b/src/app/(protected)/locations/_components/site-detail-panel.tsx new file mode 100644 index 00000000..06e78919 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-panel.tsx @@ -0,0 +1,132 @@ +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"; + +export const SITE_PANEL_WIDTH = 420; + +interface SiteDetailPanelProps { + /** Selected site id; `null` closes the panel. */ + siteId: string | null; + onClose: () => void; +} + +function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) { + return ( + + + + {location?.name || "Site"} + + + {location?.clientName || " "} + + + + + ); +} + +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 ( + <> + + {isLoading && ( + + + + )} + {Boolean(error) && ( + + {error instanceof Error ? error.message : "Failed to load site"} + + )} + {location !== undefined && mode === "edit" && ( + setMode("view")} /> + )} + {location !== undefined && mode === "view" && ( + <> + + + + + {canDelete && ( + + )} + + + setIsDeleteOpen(false)} + onDeleted={onClose} + /> + + )} + + ); +} + +/** + * Non-modal site panel: the table stays interactive, so clicking another row swaps the + * content in place. Escape closes it unless a popup inside already handled the key. + */ +export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) { + const open = siteId !== null; + + useEffect(() => { + if (!open) { + return undefined; + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && !event.defaultPrevented) { + onClose(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [open, onClose]); + + return ( + + {siteId !== null && } + + ); +} diff --git a/src/app/(protected)/locations/_components/site-detail-view.tsx b/src/app/(protected)/locations/_components/site-detail-view.tsx new file mode 100644 index 00000000..9ba956f6 --- /dev/null +++ b/src/app/(protected)/locations/_components/site-detail-view.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from "react"; +import { Link, Stack, Typography } from "@mui/material"; +import { getAreaForState } from "@/data/areas"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; +import { SiteMapPreview } from "./site-map-preview"; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} + +function formatState(state?: string): string { + const area = getAreaForState(state); + const code = state?.trim() ?? ""; + if (!code) { + return "—"; + } + return area === null ? code : `${code} · Area: ${area}`; +} + +/** Read-only site details, in the prototype order. */ +export function SiteDetailView({ location }: { location: Location }) { + const contacts = location.contacts ?? []; + + return ( + + {location.name || "—"} + {location.clientName || "—"} + + {Boolean(location.address) && ( + + {location.address} + + )} + {!location.address && "—"} + + + {location.city || "—"} + {formatState(location.state)} + + + {location.sitePhone || "—"} + {location.contactEmail || "—"} + + {contacts.length === 0 && "—"} + + {contacts.map((contact, index) => ( +
  • + {index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name} + {Boolean(contact.phone) && ` · ${contact.phone}`} +
  • + ))} +
    +
    +
    + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-fields.tsx b/src/app/(protected)/locations/_components/site-form-fields.tsx new file mode 100644 index 00000000..1f94d7fa --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-fields.tsx @@ -0,0 +1,111 @@ +import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form"; +import { Stack, TextField } from "@mui/material"; +import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field"; +import { normalizeStateCode } from "@/data/areas"; +import type { AccountListItem } from "@/domain/accounts/types/account"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import { ClientSelect } from "./client-select"; +import { LocationContactsFieldArray } from "./location-contacts-field-array"; +import { SiteMapPreview } from "./site-map-preview"; +import { StateSelect } from "./state-select"; + +interface SiteFormFieldsProps { + form: UseFormReturn; + /** Edit mode: Site Code is immutable after creation. */ + siteCodeReadOnly?: boolean; + initialClient?: Pick; +} + +/** Shared Add Site / edit-mode fields, in the prototype order. */ +export function SiteFormFields({ + form, + siteCodeReadOnly = false, + initialClient, +}: SiteFormFieldsProps) { + const { + control, + register, + formState: { errors }, + } = form; + const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] }); + const { field: addressField } = useController({ control, name: "address" }); + const { field: cityField } = useController({ control, name: "city" }); + const { field: stateField } = useController({ control, name: "state" }); + + return ( + + ( + field.onChange(event.target.value.toUpperCase())} + label="Site Code" + placeholder="e.g. AB12" + required={!siteCodeReadOnly} + disabled={siteCodeReadOnly} + error={Boolean(errors.name)} + helperText={ + errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined) + } + slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }} + fullWidth + /> + )} + /> + + { + // One pick fills all three, each through its own controller so the + // form marks them dirty and revalidates (edit mode's Save depends on it). + addressField.onChange(parts.street); + cityField.onChange(parts.city); + stateField.onChange(normalizeStateCode(parts.state) ?? ""); + }} + label="Street Address" + required + requiredMarker + error={Boolean(errors.address)} + helperText={ + errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own." + } + /> + + + + + + + + + + + + ); +} diff --git a/src/app/(protected)/locations/_components/site-form-values.ts b/src/app/(protected)/locations/_components/site-form-values.ts new file mode 100644 index 00000000..94cb859e --- /dev/null +++ b/src/app/(protected)/locations/_components/site-form-values.ts @@ -0,0 +1,24 @@ +import { normalizeStateCode } from "@/data/areas"; +import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; +import type { Location } from "@/domain/locations/types/location"; + +/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */ +export function toSiteFormValues(location: Location): LocationFormValues { + const contacts = + location.contacts && location.contacts.length > 0 + ? location.contacts + : [{ name: location.contact ?? "", phone: location.phone ?? "" }]; + + return { + name: location.name ?? "", + accountId: location.accountId != null ? String(location.accountId) : "", + address: location.address ?? "", + city: location.city ?? "", + state: normalizeStateCode(location.state) ?? "", + sitePhone: location.sitePhone ?? "", + contactEmail: location.contactEmail ?? "", + contacts, + zipCode: location.zipCode ?? "", + title: location.title ?? "", + }; +} diff --git a/src/app/(protected)/locations/_components/site-map-preview.tsx b/src/app/(protected)/locations/_components/site-map-preview.tsx new file mode 100644 index 00000000..4ff33a9d --- /dev/null +++ b/src/app/(protected)/locations/_components/site-map-preview.tsx @@ -0,0 +1,39 @@ +import { Box, Typography } from "@mui/material"; +import type { Location } from "@/domain/locations/types/location"; +import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps"; + +type SiteMapPreviewProps = Pick; + +/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */ +export function SiteMapPreview(props: SiteMapPreviewProps) { + const complete = hasCompleteSiteAddress(props); + + return ( + + + Location preview + + {complete && ( + + )} + {!complete && ( + + + Enter an address to preview the location + + + )} + + ); +} diff --git a/src/app/(protected)/locations/edit/[id].tsx b/src/app/(protected)/locations/edit/[id].tsx deleted file mode 100644 index f25ea225..00000000 --- a/src/app/(protected)/locations/edit/[id].tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../_components/location-form"; diff --git a/src/app/(protected)/locations/index.tsx b/src/app/(protected)/locations/index.tsx index 79674137..6492b96b 100644 --- a/src/app/(protected)/locations/index.tsx +++ b/src/app/(protected)/locations/index.tsx @@ -1,5 +1,4 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router"; +import { useCallback, useMemo, useState } from "react"; import { Alert, Box, @@ -10,25 +9,24 @@ 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"; export default function LocationsListPage() { - const navigate = useNavigate(); const [search, setSearch] = useState(""); 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]); @@ -47,18 +45,11 @@ export default function LocationsListPage() { const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; - const handleDeleteConfirm = () => { - if (!deleteTarget) { - return; - } - - deleteLocation.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), - }); - }; - return ( - + - + - Locations + Sites - + {totalCount} total {isFetching && !isLoading && } - + { @@ -109,24 +83,24 @@ export default function LocationsListPage() { setSearch(event.target.value); setPage(0); }} - placeholder="Search locations..." + placeholder="Search sites..." className="min-w-[220px]" /> - {Boolean(error) && ( - {error instanceof Error ? error.message : "Failed to load locations"} + {error instanceof Error ? error.message : "Failed to load sites"} )} navigate(`/locations/edit/${row.id}`)} - onDelete={setDeleteTarget} + selectedId={selectedId} + onOpen={(row) => setSelectedId(String(row.id))} /> - setDeleteTarget(null)} - onConfirm={handleDeleteConfirm} - /> + setIsAddOpen(false)} /> + ); } diff --git a/src/app/(protected)/locations/new.tsx b/src/app/(protected)/locations/new.tsx deleted file mode 100644 index da675675..00000000 --- a/src/app/(protected)/locations/new.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./_components/location-form"; diff --git a/src/app/(protected)/team-members/_components/team-member-permissions.ts b/src/app/(protected)/team-members/_components/team-member-permissions.ts index bd1097f5..1a746caf 100644 --- a/src/app/(protected)/team-members/_components/team-member-permissions.ts +++ b/src/app/(protected)/team-members/_components/team-member-permissions.ts @@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record = { }; export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys); +export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number]; const DISPATCHER_DEFAULTS = new Set([ "createVendors", "editVendors", 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/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx index b21120b7..148aeee9 100644 --- a/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx +++ b/src/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections.tsx @@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({ /> - + {selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
    {ALL_WIZARD_STATUSES.map((status) => ( @@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({ onClick={() => toggleIn("statuses", status)} /> ))} + setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))} + />
    diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts index e28e1f10..9004fbf4 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers.ts @@ -1,5 +1,5 @@ import type { Location } from "@/domain/locations/types/location"; -import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; @@ -40,6 +40,5 @@ export function resolveLocationId( } export function buildMapsUrl(location: Location): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; + return buildSiteMapsUrl(location); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx index 71cbfd89..673a638d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details.tsx @@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { Location } from "@/domain/locations/types/location"; import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps"; import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; -function buildMapsUrl(location: Location): string { - const query = formatLocationAddressPreview(location); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; -} - type WizardStepLocationSiteDetailsProps = { selectedLoc: LocationOption | undefined; locationName: string; @@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({ )} {Boolean(addressPreview) && ( { - setAdvApplied(cloneAdvancedFilters(filters)); + setAdvApplied(resolveAppliedAdvancedFilters(filters)); setPage(0); }; diff --git a/src/components/common/address-autocomplete-field.tsx b/src/components/common/address-autocomplete-field.tsx index 30e344c6..77b9343e 100644 --- a/src/components/common/address-autocomplete-field.tsx +++ b/src/components/common/address-autocomplete-field.tsx @@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = { label?: string; placeholder?: string; required?: boolean; + /** + * Show MUI's required asterisk on the label. Sites marks required fields that + * way; Vendors spells "(required)" in the label and asserts no asterisk. + */ + requiredMarker?: boolean; error?: boolean; helperText?: string; disabled?: boolean; @@ -35,6 +40,7 @@ export function AddressAutocompleteField({ label = "Street Address", placeholder = "Start typing the street address…", required = false, + requiredMarker = false, error = false, helperText, disabled = false, @@ -74,9 +80,9 @@ export function AddressAutocompleteField({ error={error} helperText={helperText} fullWidth - // Required is set on the input itself, never via MUI's `required` - // prop: this codebase spells the requirement in the label text and - // asserts that no generated asterisk renders anywhere. + required={requiredMarker} + // Required is set on the input itself; MUI's `required` prop only + // adds the label asterisk, which Vendors must not render. slotProps={{ ...params.slotProps, htmlInput: { ...params.slotProps?.htmlInput, required }, diff --git a/src/components/notifications/notification-feed-actions.tsx b/src/components/notifications/notification-feed-actions.tsx index 3df626e2..64938205 100644 --- a/src/components/notifications/notification-feed-actions.tsx +++ b/src/components/notifications/notification-feed-actions.tsx @@ -1,3 +1,4 @@ +import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections"; import type { NotificationCenter } from "./use-notification-center"; type NotificationFeedActionsProps = { @@ -6,7 +7,8 @@ type NotificationFeedActionsProps = { export function NotificationFeedActions({ center }: NotificationFeedActionsProps) { const hasUnread = center.unreadIds.size > 0; - const hasItems = center.sections.length > 0; + // Rows that need acknowledging are never cleared, so they alone do not enable "Clear all". + const hasItems = center.sections.some((section) => section.items.some(isDismissable)); return ( <> diff --git a/src/components/notifications/notification-feed-list.tsx b/src/components/notifications/notification-feed-list.tsx index 48215231..701de2c1 100644 --- a/src/components/notifications/notification-feed-list.tsx +++ b/src/components/notifications/notification-feed-list.tsx @@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) { unread={center.unreadIds.has(item.id)} onOpen={center.open} onDismiss={center.dismiss} + onAcknowledge={center.acknowledge} + acknowledging={ + item.target.workOrderId != null && + item.target.workOrderId === center.acknowledgingWorkOrderId + } /> ))} diff --git a/src/components/notifications/notification-row.tsx b/src/components/notifications/notification-row.tsx index ed254ab7..a8f1e5f0 100644 --- a/src/components/notifications/notification-row.tsx +++ b/src/components/notifications/notification-row.tsx @@ -53,8 +53,37 @@ type NotificationRowProps = { unread: boolean; onOpen: (item: NotificationItem, url?: string) => void; onDismiss: (item: NotificationItem) => void; + onAcknowledge?: (item: NotificationItem) => void; + acknowledging?: boolean; }; +/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */ +function AcknowledgeButton({ + item, + onAcknowledge, + acknowledging = false, +}: Pick) { + return ( + + ); +} + /** A vendor conflict spans several work orders, so each one stays reachable from the row. */ function WorkOrderLinks({ item, onOpen }: Pick) { if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) { @@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick )} + {item.rowType === "acknowledge" && ( + + )} ); } diff --git a/src/components/notifications/sla-at-risk-banner.tsx b/src/components/notifications/sla-at-risk-banner.tsx new file mode 100644 index 00000000..d2f6aba1 --- /dev/null +++ b/src/components/notifications/sla-at-risk-banner.tsx @@ -0,0 +1,90 @@ +import { useEffect, useMemo } from "react"; +import { AlertTriangle, ChevronRight, X } from "lucide-react"; +import { useNavigate } from "react-router"; +import { toast } from "react-toastify"; +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; +import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; +import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy"; +import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter"; + +const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = []; + +/** Toasts each at-risk work order once per session, the first time the feed reports it. */ +function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) { + const { claimSlaToasts } = useNotificationSession(); + + useEffect(() => { + const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id))); + atRisk + .filter((workOrder) => claimed.has(workOrder.id)) + .forEach((workOrder) => + toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }), + ); + }, [atRisk, claimSlaToasts]); +} + +/** + * One line at the top of every page while any of the user's Reactive/Emergency work orders is past + * half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing + * hides them for this session, and a work order that becomes at risk later brings the banner back. + */ +export function SlaAtRiskBanner() { + const navigate = useNavigate(); + const { data } = useNotificationFeed(); + const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession(); + const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS; + useSlaHalfwayToasts(atRisk); + + const shown = useMemo( + () => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)), + [atRisk, slaBannerDismissedIds], + ); + if (shown.length === 0) { + return null; + } + + const ids = shown.map((workOrder) => workOrder.id); + return ( +
    + + +
    + ); +} diff --git a/src/components/notifications/use-notification-center.ts b/src/components/notifications/use-notification-center.ts index a466b257..ccc6a491 100644 --- a/src/components/notifications/use-notification-center.ts +++ b/src/components/notifications/use-notification-center.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useNavigate } from "react-router"; import type { NotificationItem } from "@/domain/notifications/types/notification-feed"; import { useNotificationSession } from "@/domain/notifications/session/notification-session-context"; +import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed"; import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url"; import { @@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) { [items, readIds], ); + const acknowledgeBreach = useAcknowledgeSlaBreach(); + const acknowledge = (item: NotificationItem) => { + if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return; + acknowledgeBreach.mutate(item.target.workOrderId); + }; + const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null; + const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => { markRead([item.id]); if (url == null) return; @@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) { error: feed.error, open, dismiss: (item: NotificationItem) => dismiss([item.id]), + acknowledge, + acknowledgingWorkOrderId, dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)), markAllRead: () => markRead(items.map((item) => item.id)), }; 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/auth/api/permissions-api.ts b/src/domain/auth/api/permissions-api.ts new file mode 100644 index 00000000..292fce4c --- /dev/null +++ b/src/domain/auth/api/permissions-api.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { apiGet } from "@/api/api"; +import { API_PATHS } from "@/api/api-paths"; +import { handleApiResponse } from "@/api/handle-api-response"; + +export const UNEXPECTED_PERMISSIONS_RESPONSE = + "The server sent an unexpected permissions response."; + +const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) }); + +/** Keys the signed-in user holds after role defaults and their individual overrides. */ +export async function getMyPermissions(): Promise { + const data = await apiGet(API_PATHS.teamMember.myPermissions); + const result = effectivePermissionsSchema.safeParse(handleApiResponse(data)); + if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE); + return result.data.permissions; +} diff --git a/src/domain/auth/use-cases/use-permissions.ts b/src/domain/auth/use-cases/use-permissions.ts new file mode 100644 index 00000000..dab5649c --- /dev/null +++ b/src/domain/auth/use-cases/use-permissions.ts @@ -0,0 +1,32 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions"; +import { getMyPermissions } from "@/domain/auth/api/permissions-api"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { useAuthContext } from "@/providers/auth-context"; + +/** + * The signed-in user's effective permissions as the server computes them. Keyed by user so a + * different sign-in never reads another person's answer, and refetched on every mount and window + * focus so an override change applies on the next page load. + */ +export function usePermissions(): UseQueryResult { + const { user } = useAuthContext(); + const userId = user?.id ?? null; + return useQuery({ + queryKey: queryKeys.auth.permissions(userId ?? ""), + queryFn: getMyPermissions, + enabled: userId !== null, + staleTime: 0, + refetchOnWindowFocus: true, + meta: { suppressErrorToast: true }, + }); +} + +/** Holds only for a successful server answer that lists the key; loading and errors deny. */ +export function hasPermission(query: UseQueryResult, key: PermissionKey): boolean { + return !query.isError && query.data?.includes(key) === true; +} + +export function useHasPermission(key: PermissionKey): boolean { + return hasPermission(usePermissions(), key); +} 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 2f9f1376..728980e8 100644 --- a/src/domain/locations/api/locations-api.ts +++ b/src/domain/locations/api/locations-api.ts @@ -6,10 +6,12 @@ import { mapLocationListItems, mapLocationSites, mapLocationToBackend, + mapSiteOpenWorkOrders, } from "@/domain/locations/mappers/location-mapper"; import type { Location, LocationSite, + SiteOpenWorkOrders, LocationsListParams, LocationsListResult, } from "@/domain/locations/types/location"; @@ -74,4 +76,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 6713c559..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 { @@ -18,6 +19,14 @@ function readString(record: Record, ...keys: string[]): string return ""; } +/** Like readString, but an empty value becomes undefined. */ +function readOptionalString( + record: Record, + ...keys: string[] +): string | undefined { + return readString(record, ...keys) || undefined; +} + function readId(record: Record): string | number { const value = record.id ?? record.Id; if (typeof value === "string" || typeof value === "number") { @@ -123,6 +132,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem { return { id: readId(item), name: readString(item, "name", "Name"), + clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: readString(item, "phone", "Phone"), address: readString(item, "address", "Address"), city: readString(item, "city", "City"), state: readString(item, "state", "State"), @@ -154,21 +165,30 @@ export function mapLocation(raw: unknown): Location { const contacts = mapLocationContacts(item.contacts ?? item.Contacts); const mainContact = contacts[0]; - const legacyPhone = readString(item, "phone", "Phone") || undefined; - const legacyContact = - readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined; + const legacyPhone = readOptionalString(item, "phone", "Phone"); + const legacyContact = readOptionalString( + item, + "contact", + "Contact", + "contactPerson", + "ContactPerson", + ); return { id: readId(item) || undefined, - name: readString(item, "name", "Name") || undefined, - address: address || undefined, - city: readString(item, "city", "City") || undefined, - state: readString(item, "state", "State") || undefined, - zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined, + name: readOptionalString(item, "name", "Name"), + title: readOptionalString(item, "title", "Title"), + clientName: readOptionalString(item, "clientName", "ClientName", "accountName", "AccountName"), + sitePhone: legacyPhone, + notes: readOptionalString(item, "notes", "Notes"), + address, + city: readOptionalString(item, "city", "City"), + state: readOptionalString(item, "state", "State"), + zipCode: readOptionalString(item, "zipCode", "ZipCode", "Zip"), phone: mainContact ? mainContact.phone : legacyPhone, contact: mainContact ? mainContact.name : legacyContact, - contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined, - status: readString(item, "status", "Status") || undefined, + contactEmail: readOptionalString(item, "contactEmail", "ContactEmail"), + status: readOptionalString(item, "status", "Status"), accountId: readOptionalId(item) || undefined, contacts: contacts.length > 0 ? contacts : undefined, }; @@ -196,6 +216,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] { .filter((contact) => contact.name !== "" || contact.phone !== ""); } +/** + * Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`, + * `title` and `notes` are sent only when present so an update keeps the stored values. + */ export function mapLocationToBackend(location: unknown): Record { const item = (typeof location === "object" && location !== null ? location : {}) as Record< string, @@ -206,19 +230,25 @@ export function mapLocationToBackend(location: unknown): Record const mainContact = contacts[0]; const payload: Record = { - name: item.name ?? "", + name: typeof item.name === "string" ? item.name.trim() : "", address: item.address ?? "", city: item.city ?? "", state: item.state ?? "", zipCode: item.zipCode ?? "", - phone: mainContact ? mainContact.phone : (item.phone ?? ""), - contact: mainContact ? mainContact.name : (item.contact ?? ""), - contactEmail: item.contactEmail ?? "", - status: item.status ?? "Active", + phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "", + // A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name. + contact: mainContact ? mainContact.name : readString(item, "contact").trim(), + contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "", accountId: item.accountId ?? "", contacts, }; + for (const key of ["status", "title", "notes"] as const) { + if (typeof item[key] === "string") { + payload[key] = item[key]; + } + } + const id = item.id ?? item.Id; if (typeof id === "string" || typeof id === "number") { payload.id = id; @@ -226,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/schemas/location-schema.ts b/src/domain/locations/schemas/location-schema.ts index 7c9e878c..a45b7b90 100644 --- a/src/domain/locations/schemas/location-schema.ts +++ b/src/domain/locations/schemas/location-schema.ts @@ -1,29 +1,43 @@ import { z } from "zod"; import { US_STATE_CODES } from "@/data/areas"; -import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location"; export const locationContactSchema = z.object({ id: z.number().int().positive().optional(), name: z .string() .trim() - .min(1, "Contact person is required") - .max(100, "Contact person cannot exceed 100 characters"), - phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"), + .min(1, "Contact name is required") + .max(100, "Contact name cannot exceed 100 characters"), + phone: z + .string() + .trim() + .min(1, "Contact phone is required") + .max(20, "Contact phone cannot exceed 20 characters"), }); +/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */ export const locationSchema = z.object({ - name: z.string().min(1, "Name is required"), - accountId: z.string().optional(), - address: z.string().min(1, "Address is required"), - city: z.string().min(1, "City is required"), + name: z + .string() + .trim() + .min(1, "Site Code is required") + .max(50, "Site Code cannot exceed 50 characters"), + accountId: z.string().min(1, "Client is required"), + address: z.string().trim().min(1, "Street Address is required"), + city: z.string().trim().min(1, "City is required"), state: z .union([z.enum(US_STATE_CODES), z.literal("")]) .refine((state): boolean => state !== "", { message: "State is required" }), - zipCode: z.string().min(1, "Zip code is required"), + sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"), + contactEmail: z + .string() + .trim() + .refine((value) => value === "" || z.email().safeParse(value).success, { + message: "Invalid email address", + }), contacts: z.array(locationContactSchema).min(1, "At least one contact is required"), - contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"), - status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }), + zipCode: z.string(), + title: z.string(), }); export type LocationFormValues = z.infer; @@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = { address: "", city: "", state: "", - zipCode: "", - contacts: [{ name: "", phone: "" }], + sitePhone: "", contactEmail: "", - status: "Active", + contacts: [{ name: "", phone: "" }], + zipCode: "", + title: "", }; diff --git a/src/domain/locations/types/location.ts b/src/domain/locations/types/location.ts index 7fa99625..16829026 100644 --- a/src/domain/locations/types/location.ts +++ b/src/domain/locations/types/location.ts @@ -10,6 +10,10 @@ export interface LocationContact { export interface LocationListItem { id: string | number; name: string; + /** Client (account) display name. */ + clientName: string; + /** Site Phone, independent of the contacts. */ + sitePhone: string; address: string; city: string; state: string; @@ -26,6 +30,10 @@ export interface LocationListItem { export interface Location { id?: string | number; name?: string; + title?: string; + clientName?: string; + sitePhone?: string; + notes?: string; address?: string; city?: string; state?: string; @@ -62,4 +70,8 @@ export interface LocationsListResult { pageSize: number; } -export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const; +/** 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-create-location.ts b/src/domain/locations/use-cases/use-create-location.ts index 421de42c..22bbd202 100644 --- a/src/domain/locations/use-cases/use-create-location.ts +++ b/src/domain/locations/use-cases/use-create-location.ts @@ -1,24 +1,27 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; +import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useCreateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */ +export function useCreateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (values: LocationFormValues) => locationsApi.create(values), + mutationFn: async (values: LocationFormValues) => { + await locationsApi.create({ ...values, status: "Active" }); + }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location created successfully!"); - navigate("/locations"); + toast.success("Site added"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to create location"); + if (isDuplicateSiteCodeError(error)) { + return; + } + toast.error(error.message || "Failed to add site"); }, }); } diff --git a/src/domain/locations/use-cases/use-delete-location.ts b/src/domain/locations/use-cases/use-delete-location.ts index 4e710337..598b6126 100644 --- a/src/domain/locations/use-cases/use-delete-location.ts +++ b/src/domain/locations/use-cases/use-delete-location.ts @@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult locationsApi.delete(id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - toast.success("Location deleted successfully!"); + toast.success("Site deleted"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to delete location"); + toast.error(error.message || "Failed to delete site"); }, }); } 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/locations/use-cases/use-update-location.ts b/src/domain/locations/use-cases/use-update-location.ts index 02e271c6..05d8b874 100644 --- a/src/domain/locations/use-cases/use-update-location.ts +++ b/src/domain/locations/use-cases/use-update-location.ts @@ -1,9 +1,7 @@ import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; -import { useNavigate } from "react-router"; import { toast } from "react-toastify"; import { locationsApi } from "@/domain/locations/api/locations-api"; import type { LocationFormValues } from "@/domain/locations/schemas/location-schema"; -import type { Location } from "@/domain/locations/types/location"; import { queryKeys } from "@/infra/query-key/query-key"; interface UpdateLocationInput { @@ -11,20 +9,25 @@ interface UpdateLocationInput { values: LocationFormValues; } -export function useUpdateLocation(): UseMutationResult { - const navigate = useNavigate(); +/** Saves a site. The response carries no site, so the list and detail are refetched. */ +export function useUpdateLocation(): UseMutationResult { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values), - onSuccess: (_data, variables) => { - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }); - void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) }); - toast.success("Location updated successfully!"); - navigate("/locations"); + mutationFn: async ({ id, values }: UpdateLocationInput) => { + await locationsApi.update(id, values); + }, + onSuccess: async (_data, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }), + queryClient.invalidateQueries({ + queryKey: queryKeys.locations.detail(String(variables.id)), + }), + ]); + toast.success("Site updated"); }, onError: (error: Error) => { - toast.error(error.message || "Failed to update location"); + toast.error(error.message || "Failed to update site"); }, }); } diff --git a/src/domain/locations/utils/site-code-conflict.ts b/src/domain/locations/utils/site-code-conflict.ts new file mode 100644 index 00000000..5c2e8e2a --- /dev/null +++ b/src/domain/locations/utils/site-code-conflict.ts @@ -0,0 +1,14 @@ +import { isHTTPError } from "ky"; + +export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists."; + +/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */ +export function isDuplicateSiteCodeError(error: unknown): boolean { + if (!isHTTPError(error)) { + return false; + } + const data = error.data; + const code = + data && typeof data === "object" ? (data as Record).code : undefined; + return error.response.status === 409 || code === "DuplicateSiteCode"; +} diff --git a/src/domain/locations/utils/site-maps.ts b/src/domain/locations/utils/site-maps.ts new file mode 100644 index 00000000..28a75792 --- /dev/null +++ b/src/domain/locations/utils/site-maps.ts @@ -0,0 +1,23 @@ +import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import type { Location } from "@/domain/locations/types/location"; + +type SiteAddress = Pick; + +/** Google Maps search link for a site address; opens in a new tab. */ +export function buildSiteMapsUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; +} + +/** True once Street, City and State are all filled, so a map preview can render. */ +export function hasCompleteSiteAddress(location: SiteAddress): boolean { + return [location.address, location.city, location.state].every( + (part) => (part?.trim() ?? "") !== "", + ); +} + +/** Key-less Google Maps embed URL for the read-only preview. */ +export function buildSiteMapEmbedUrl(location: SiteAddress): string { + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`; +} diff --git a/src/domain/notifications/api/notifications-api.ts b/src/domain/notifications/api/notifications-api.ts index 034556f2..d17bd877 100644 --- a/src/domain/notifications/api/notifications-api.ts +++ b/src/domain/notifications/api/notifications-api.ts @@ -1,4 +1,4 @@ -import { apiGet } from "@/api/api"; +import { apiGet, apiPostNoContent } from "@/api/api"; import { notificationFeedSchema, type NotificationFeed, @@ -7,4 +7,7 @@ import { export const notificationsApi = { feed: async (): Promise => notificationFeedSchema.parse(await apiGet("notifications")), + /** Records who acknowledged the missed response deadline in the work order's audit history. */ + acknowledgeSlaBreach: (workOrderId: number): Promise => + apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`), }; diff --git a/src/domain/notifications/session/notification-session-context.ts b/src/domain/notifications/session/notification-session-context.ts index c5697e97..01ddac7c 100644 --- a/src/domain/notifications/session/notification-session-context.ts +++ b/src/domain/notifications/session/notification-session-context.ts @@ -5,6 +5,11 @@ export type NotificationSession = { readIds: ReadonlySet; dismiss: (ids: string[]) => void; markRead: (ids: string[]) => void; + /** At-risk work orders hidden from the SLA banner for this session. */ + slaBannerDismissedIds: ReadonlySet; + dismissSlaBanner: (workOrderIds: number[]) => void; + /** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */ + claimSlaToasts: (workOrderIds: number[]) => number[]; }; export const NotificationSessionContext = createContext(null); diff --git a/src/domain/notifications/session/notification-session-provider.tsx b/src/domain/notifications/session/notification-session-provider.tsx index 87bae800..fc389b5d 100644 --- a/src/domain/notifications/session/notification-session-provider.tsx +++ b/src/domain/notifications/session/notification-session-provider.tsx @@ -1,7 +1,7 @@ -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; import { NotificationSessionContext } from "./notification-session-context"; -function addAll(current: ReadonlySet, ids: string[]): ReadonlySet { +function addAll(current: ReadonlySet, ids: T[]): ReadonlySet { if (ids.every((id) => current.has(id))) { return current; } @@ -17,6 +17,11 @@ function addAll(current: ReadonlySet, ids: string[]): ReadonlySet>(() => new Set()); const [readIds, setReadIds] = useState>(() => new Set()); + const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState>( + () => new Set(), + ); + // A ref, not state: claiming must be synchronous so a re-run effect can never toast twice. + const toastedSlaIds = useRef(new Set()); const dismiss = useCallback((ids: string[]) => { setDismissedIds((current) => addAll(current, ids)); @@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode const markRead = useCallback((ids: string[]) => { setReadIds((current) => addAll(current, ids)); }, []); + const dismissSlaBanner = useCallback((workOrderIds: number[]) => { + setSlaBannerDismissedIds((current) => addAll(current, workOrderIds)); + }, []); + const claimSlaToasts = useCallback((workOrderIds: number[]) => { + const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id)); + claimed.forEach((id) => toastedSlaIds.current.add(id)); + return claimed; + }, []); const value = useMemo( - () => ({ dismissedIds, readIds, dismiss, markRead }), - [dismissedIds, readIds, dismiss, markRead], + () => ({ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + }), + [ + dismissedIds, + readIds, + dismiss, + markRead, + slaBannerDismissedIds, + dismissSlaBanner, + claimSlaToasts, + ], ); return ( diff --git a/src/domain/notifications/types/notification-feed.ts b/src/domain/notifications/types/notification-feed.ts index fc5e7bdf..af242b17 100644 --- a/src/domain/notifications/types/notification-feed.ts +++ b/src/domain/notifications/types/notification-feed.ts @@ -50,9 +50,21 @@ const sectionSchema = z.object({ items: z.array(itemSchema), }); +/** A work order past half of its SEV response window, with the server-computed clock. */ +const slaAtRiskSchema = z.object({ + id: z.number(), + number: z.string().nullish(), + severity: z.number(), + startedAt: z.string(), + deadlineAt: z.string(), + percentElapsed: z.number(), +}); + export const notificationFeedSchema = z.object({ generatedAt: z.string(), sections: z.array(sectionSchema), + // Defaulted so a feed from a server without SLA alerts still parses. + slaAtRisk: z.array(slaAtRiskSchema).default([]), }); export type NotificationSeverity = z.infer; @@ -60,3 +72,4 @@ export type NotificationTarget = z.infer; export type NotificationItem = z.infer; export type NotificationSection = z.infer; export type NotificationFeed = z.infer; +export type SlaAtRiskWorkOrder = z.infer; diff --git a/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts new file mode 100644 index 00000000..fb1fc62e --- /dev/null +++ b/src/domain/notifications/use-cases/use-acknowledge-sla-breach.ts @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationsApi } from "@/domain/notifications/api/notifications-api"; +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { queryKeys } from "@/infra/query-key/query-key"; + +function isBreachRowFor(workOrderId: number) { + return (item: NotificationItem) => + item.reason === "sla" && + item.rowType === "acknowledge" && + item.target.workOrderId === workOrderId; +} + +/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */ +export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed { + const matches = isBreachRowFor(workOrderId); + return { + ...feed, + sections: feed.sections.map((section) => { + const removed = section.items.filter(matches); + if (removed.length === 0) return section; + return { + ...section, + items: section.items.filter((item) => !matches(item)), + count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0), + }; + }), + }; +} + +export function useAcknowledgeSlaBreach() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId), + onSuccess: (_result, workOrderId) => { + queryClient.setQueryData(queryKeys.notifications.feed(), (feed) => + feed ? withoutSlaBreach(feed, workOrderId) : feed, + ); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }), + }); +} diff --git a/src/domain/notifications/utils/sla-alert-copy.ts b/src/domain/notifications/utils/sla-alert-copy.ts new file mode 100644 index 00000000..8f9e26a2 --- /dev/null +++ b/src/domain/notifications/utils/sla-alert-copy.ts @@ -0,0 +1,20 @@ +import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed"; + +function workOrderLabel(workOrder: Pick): string { + return workOrder.number != null && workOrder.number !== "" + ? `WO #${workOrder.number}` + : `WO ${workOrder.id}`; +} + +/** One line whatever the count, as the top-of-app banner shows it. */ +export function slaBannerText(workOrders: Pick[]): string { + if (workOrders.length === 1) { + return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`; + } + return `${workOrders.length} work orders are at risk of missing their response deadline`; +} + +/** The one-time toast when a work order is first seen past half of its response window. */ +export function slaHalfwayToastText(workOrder: Pick): string { + return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`; +} diff --git a/src/domain/services/api/services-api.ts b/src/domain/services/api/services-api.ts index d725d828..df781742 100644 --- a/src/domain/services/api/services-api.ts +++ b/src/domain/services/api/services-api.ts @@ -20,10 +20,13 @@ const SERVICE_WORK_ORDER_TYPE_API = { Reactive: 6, } as const; -const TYPE_TO_API: Record, number> = { +// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the +// client never confirmed, so it lists the PM catalog. +const TYPE_TO_API: Record = { PM: WORK_ORDER_TYPE_API.PM, Reactive: WORK_ORDER_TYPE_API.Reactive, Emergency: WORK_ORDER_TYPE_API.Emergency, + Overdue: WORK_ORDER_TYPE_API.PM, }; function toApiInput(input: ServiceInput) { @@ -51,7 +54,6 @@ export const servicesApi = { }, getActiveForWorkOrderType: async (workOrderType: WOType): Promise => { - if (workOrderType === "Overdue") return []; const data = await apiGet(API_PATHS.services.list, { searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] }, }); diff --git a/src/domain/services/use-cases/use-services-for-work-order-type.ts b/src/domain/services/use-cases/use-services-for-work-order-type.ts index 6dd8d4ac..f712051e 100644 --- a/src/domain/services/use-cases/use-services-for-work-order-type.ts +++ b/src/domain/services/use-cases/use-services-for-work-order-type.ts @@ -11,7 +11,6 @@ export function useServicesForWorkOrderType( return useQuery({ queryKey: queryKeys.services.byWorkOrderType(effectiveType), queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType), - enabled: effectiveType !== "Overdue", staleTime: 60 * 1000, }); } 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/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts index e45381e7..b4940692 100644 --- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts @@ -1,4 +1,5 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board"; +import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper"; @@ -64,7 +65,7 @@ export function assignWizardServicePocFields( payload.vendorNotes = vendorNotes; } - if (draft.severity != null) { + if (draft.severity != null && typeNeedsSeverity(draft.type)) { payload.severity = draft.severity; } diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index af00a55d..f1f046f9 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record = { Reactive: WORK_ORDER_TYPE_API.Reactive, - Overdue: WORK_ORDER_TYPE_API.Other, + Overdue: WORK_ORDER_TYPE_API.Overdue, }; /** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */ diff --git a/src/domain/work-orders/types/work-order-advanced-filters.ts b/src/domain/work-orders/types/work-order-advanced-filters.ts index 66f831dc..6b8ba659 100644 --- a/src/domain/work-orders/types/work-order-advanced-filters.ts +++ b/src/domain/work-orders/types/work-order-advanced-filters.ts @@ -35,6 +35,8 @@ export interface WorkOrderAdvancedFilters { dispatchers: Set; regions: Set; statuses: Set; + /** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */ + pastDue: boolean; rescheduled: boolean; carriedOver: boolean; addOn: boolean; @@ -46,6 +48,8 @@ export interface WorkOrderAdvancedFilters { internalOnly: boolean; hasUplift: boolean; upliftStatuses: Set; + /** Exact work orders to show; when non-empty the server ignores every other filter. */ + workOrderIds: Set; } export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { @@ -59,6 +63,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { dispatchers: new Set(), regions: new Set(), statuses: new Set(), + pastDue: false, rescheduled: false, carriedOver: false, addOn: false, @@ -70,6 +75,7 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = { internalOnly: false, hasUplift: false, upliftStatuses: new Set(), + workOrderIds: new Set(), }; export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [ diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index 317e7cad..6bfb0e28 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -17,6 +17,7 @@ export const WORK_ORDER_TYPE_API = { Inspection: 5, Reactive: 6, AddOn: 7, + Overdue: 8, Other: 99, } as const; @@ -64,6 +65,7 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record = { [WORK_ORDER_TYPE_API.PM]: "PM", [WORK_ORDER_TYPE_API.Reactive]: "Reactive", [WORK_ORDER_TYPE_API.Emergency]: "Emergency", + [WORK_ORDER_TYPE_API.Overdue]: "Overdue", }; export const LIFECYCLE_STATUS_API_TO_FE: Record = { @@ -326,6 +328,8 @@ export interface WorkOrderBoardSearchQueryParams { hasUplift?: boolean; upliftStatuses?: string[]; myWorkOrders?: boolean; + /** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */ + ids?: string; sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string; sortDir?: "asc" | "desc" | string; } diff --git a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts index c7222b82..1c4e789c 100644 --- a/src/domain/work-orders/utils/advanced-filter-chip-builders.ts +++ b/src/domain/work-orders/utils/advanced-filter-chip-builders.ts @@ -192,6 +192,15 @@ export function appendStatusPmVendorDocChips( clear: (current) => ({ ...current, statuses: new Set() }), }); + if (filters.pastDue) { + chips.push({ + key: "pastdue", + group: "Status", + value: "Past Due", + clear: (current) => ({ ...current, pastDue: false }), + }); + } + pushSetChip({ chips, key: "pm", @@ -306,3 +315,17 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder clear: clearUplift, }); } + +export function appendWorkOrderIdsChip( + chips: AdvancedFilterChip[], + filters: WorkOrderAdvancedFilters, +) { + const count = filters.workOrderIds?.size ?? 0; + if (count === 0) return; + chips.push({ + key: "workOrderIds", + group: "Work orders", + value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`, + clear: (current) => ({ ...current, workOrderIds: new Set() }), + }); +} diff --git a/src/domain/work-orders/utils/advanced-filter-predicates.ts b/src/domain/work-orders/utils/advanced-filter-predicates.ts index 45fb611e..530bbd70 100644 --- a/src/domain/work-orders/utils/advanced-filter-predicates.ts +++ b/src/domain/work-orders/utils/advanced-filter-predicates.ts @@ -45,12 +45,10 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => { const matchesSite: AdvancedFilterPredicate = (row, filters) => !filters.sites.size || filters.sites.has(row.site); -const matchesType: AdvancedFilterPredicate = (row, filters) => { - if (!filters.types.size) return true; - const overdueMatch = filters.types.has("Overdue") && row.isPastDue; - const typeMatch = filters.types.has(row.type as never); - return overdueMatch || typeMatch; -}; +const matchesType: AdvancedFilterPredicate = (row, filters) => + !filters.types.size || filters.types.has(row.type as never); + +const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue; const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => { if (!filters.dispatchers.size) return true; @@ -95,6 +93,7 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [ matchesType, matchesDispatcher, matchesStatus, + matchesPastDue, matchesPmType, matchesVendorTech, matchesDoc, diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts index 871bd0cd..e60a9435 100644 --- a/src/domain/work-orders/utils/advanced-filters-utils.ts +++ b/src/domain/work-orders/utils/advanced-filters-utils.ts @@ -12,6 +12,7 @@ import { appendSeverityChip, appendStatusPmVendorDocChips, appendUpliftChip, + appendWorkOrderIdsChip, type AdvancedFilterChip, } from "@/domain/work-orders/utils/advanced-filter-chip-builders"; import { addDaysIso, mondayOf, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -33,7 +34,9 @@ function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilte docs: new Set(filters.docs), flagColors: new Set(filters.flagColors ?? []), upliftStatuses: new Set(filters.upliftStatuses ?? []), + workOrderIds: new Set(filters.workOrderIds ?? []), unscheduledOnly: Boolean(filters.unscheduledOnly), + pastDue: Boolean(filters.pastDue), }; } @@ -45,6 +48,7 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool Boolean(filters.dispatchers?.size), Boolean(filters.regions?.size), Boolean(filters.statuses?.size), + Boolean(filters.pastDue), filters.rescheduled, filters.carriedOver, filters.addOn, @@ -56,12 +60,25 @@ const ADVANCED_FILTER_ACTIVE_SIGNALS = (filters: WorkOrderAdvancedFilters): bool filters.internalOnly, filters.hasUplift, filters.unscheduledOnly, + Boolean(filters.workOrderIds?.size), ]; export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number { return ADVANCED_FILTER_ACTIVE_SIGNALS(filters).filter(Boolean).length; } +/** + * The server honours only the id set when one is sent, so an id selection and panel facets never + * combine: applying any facet replaces the selection with a normal filtered board. + */ +export function resolveAppliedAdvancedFilters( + filters: WorkOrderAdvancedFilters, +): WorkOrderAdvancedFilters { + const next = cloneAdvancedFilters(filters); + const withoutIds = { ...next, workOrderIds: new Set() }; + return next.workOrderIds.size > 0 && countAdvancedFilters(withoutIds) > 0 ? withoutIds : next; +} + function monthStart(y: number, m: number): string { return `${y}-${pad2(m + 1)}-01`; } @@ -120,6 +137,7 @@ export function buildAdvancedFilterChips( appendIndicatorChips(chips, filters); appendFlagAndInternalChips(chips, filters); appendUpliftChip(chips, filters); + appendWorkOrderIdsChip(chips, filters); return chips; } diff --git a/src/domain/work-orders/utils/board-query-params.ts b/src/domain/work-orders/utils/board-query-params.ts index 998d0605..a027fd1e 100644 --- a/src/domain/work-orders/utils/board-query-params.ts +++ b/src/domain/work-orders/utils/board-query-params.ts @@ -12,6 +12,7 @@ import { } from "@/domain/work-orders/types/work-order-board"; import { mapWizardTypeToApi } from "@/domain/work-orders/mappers/work-order-board-mapper"; import { weekRange } from "@/domain/work-orders/utils/list-week-utils"; +import { normalizeWorkOrderIds } from "@/domain/work-orders/utils/work-order-ids-filter"; import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters"; import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; @@ -41,30 +42,19 @@ function mapDispatchers(ids: Iterable): string[] | undefined { return list.length > 0 ? list : undefined; } -/** Maps UI type filters to API `types` + separate `overdue` flag. Overdue is never sent as types=99. */ -function mapWoTypeFilters(types: Iterable): { - types?: number[]; - overdue?: boolean; -} { +/** Maps UI type filters to API `types`. The Overdue type is sent as its own enum; past due uses `overdue`. */ +function mapWoTypeFilters(types: Iterable): number[] | undefined { const list: number[] = []; - let overdue = false; for (const type of types) { if (type === "All") { continue; } - if (type === "Overdue") { - overdue = true; - continue; - } const api = mapWizardTypeToApi(type as WOType); if (api != null) { list.push(api); } } - return { - types: list.length > 0 ? list : undefined, - overdue: overdue || undefined, - }; + return list.length > 0 ? list : undefined; } function mapStatuses(statuses: Iterable): number[] | undefined { @@ -133,20 +123,24 @@ function mapSearchFacetParams( }; } +/** Sent as one comma-separated `ids` value; the server then ignores every other filter. */ +function mapWorkOrderIds(ids: Set | undefined): string | undefined { + // Normalized here too, so a caller that fills the set directly still stays within the server's limit. + const normalized = ids?.size ? normalizeWorkOrderIds(ids) : []; + return normalized.length > 0 ? normalized.join(",") : undefined; +} + /** Build GET /board query from weekly filter bar. */ export function toBoardQueryParams( bar: WorkOrderBarFilters, search: string, ): WorkOrderBoardQueryParams { const [, weekTo] = weekRange(bar.weekMonday); - const typeFilters = bar.types.size > 0 ? mapWoTypeFilters(bar.types) : {}; - return { weekStart: bar.weekMonday, weekEnd: weekTo, dispatchers: mapDispatchers(bar.dispatcherIds), - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(bar.types), search: search.trim().length >= 2 ? search.trim() : undefined, }; } @@ -203,7 +197,6 @@ export function toBoardSearchQueryParams(input: { BOARD_SEARCH_MAX_PAGE_SIZE, ); - const typeFilters = mapWoTypeFilters(filters.types); const trimmedSearch = input.search?.trim(); const search = trimmedSearch && trimmedSearch.length >= 2 ? trimmedSearch : undefined; @@ -216,8 +209,8 @@ export function toBoardSearchQueryParams(input: { dateTo: date.dateTo, includeDateless: date.includeDateless, sites: filters.sites.size > 0 ? [...filters.sites] : undefined, - types: typeFilters.types, - overdue: typeFilters.overdue, + types: mapWoTypeFilters(filters.types), + overdue: filters.pastDue || undefined, dispatchers: mapDispatchers(filters.dispatchers), regions: filters.regions.size > 0 ? [...filters.regions] : undefined, statuses: mapStatuses(filters.statuses), @@ -226,6 +219,7 @@ export function toBoardSearchQueryParams(input: { docStatuses: mapDocStatuses(filters.docs), unscheduledOnly: filters.unscheduledOnly || undefined, ...mapSearchFacetParams(filters), + ids: mapWorkOrderIds(filters.workOrderIds), sortBy: input.sortBy || "scheduledDate", sortDir: input.sortDir === "desc" ? "desc" : "asc", }; @@ -252,10 +246,6 @@ export function buildBoardSearchParamsRecord( return out; } -export function woTypeFilterIsOverdue(type: string): boolean { - return type === "Overdue"; -} - /** Week-independent GET /board/search params for the pinned Unassigned queue. */ export function toUnassignedQueueQueryParams( page = 0, diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts index bb868e51..ebc73bee 100644 --- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -5,6 +5,7 @@ import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-o import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { parseWorkOrderIdsParam } from "@/domain/work-orders/utils/work-order-ids-filter"; const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -60,9 +61,27 @@ 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 { + // An exact id set replaces every other filter: nothing else may hide one of those work orders. + const workOrderIds = parseWorkOrderIdsParam(params.get("ids")); + if (workOrderIds.length > 0) { + return { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: new Set(workOrderIds) }; + } + const dateFrom = (params.get("dateFrom") ?? "").trim(); const dateTo = (params.get("dateTo") ?? "").trim(); const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); @@ -72,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"; @@ -83,6 +103,7 @@ export function parseDashboardDrilldownParams( types.length, dispatchers.length, regions.length, + sites.length, upliftStatuses.length, hasUplift, ].some(Boolean); @@ -115,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/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts index 3d8eb132..2300335f 100644 --- a/src/domain/work-orders/utils/table-filter-utils.ts +++ b/src/domain/work-orders/utils/table-filter-utils.ts @@ -65,12 +65,7 @@ function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean { } function rowMatchesBarTypes(row: WorkOrderTableRow, types: Set): boolean { - if (types.size === 0) { - return true; - } - const overdueMatch = types.has("Overdue") && row.isPastDue; - const typeMatch = types.has(row.type as WOType); - return overdueMatch || typeMatch; + return types.size === 0 || types.has(row.type as WOType); } export function filterByBarAndSearch( diff --git a/src/domain/work-orders/utils/work-order-ids-filter.ts b/src/domain/work-orders/utils/work-order-ids-filter.ts new file mode 100644 index 00000000..fc60a7b3 --- /dev/null +++ b/src/domain/work-orders/utils/work-order-ids-filter.ts @@ -0,0 +1,26 @@ +/** The board's exact id filter accepts at most this many distinct work orders (backend limit). */ +export const WORK_ORDER_IDS_MAX = 200; + +/** Positive integer ids, deduplicated in first-seen order, capped at {@link WORK_ORDER_IDS_MAX}. */ +export function normalizeWorkOrderIds(values: Iterable): number[] { + const ids = new Set(); + for (const value of values) { + const text = typeof value === "number" ? String(value) : value.trim(); + if (!/^\d+$/.test(text)) continue; + const id = Number(text); + if (Number.isSafeInteger(id) && id > 0) ids.add(id); + if (ids.size === WORK_ORDER_IDS_MAX) break; + } + return [...ids]; +} + +/** Reads the `ids` URL param (`101,205,318`); invalid tokens are dropped. */ +export function parseWorkOrderIdsParam(raw: string | null): number[] { + return raw ? normalizeWorkOrderIds(raw.split(",")) : []; +} + +/** Work Orders showing exactly these work orders, whatever the saved or default filters are. */ +export function buildWorkOrderIdsHref(ids: number[]): string { + const normalized = normalizeWorkOrderIds(ids); + return normalized.length > 0 ? `/workorders?ids=${normalized.join(",")}` : "/workorders"; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index cbb9b6e6..35be49f4 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -10,6 +10,8 @@ export const queryKeys = { auth: { all: ["auth"] as const, session: () => [...queryKeys.auth.all, "session"] as const, + permissions: (userId: string | number) => + [...queryKeys.auth.all, "permissions", userId] as const, }, workOrders: { all: ["workOrders"] as const, @@ -84,6 +86,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) => @@ -91,6 +99,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..94f4ac7f --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-fixtures.ts @@ -0,0 +1,116 @@ +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", + }); +} + +export const MY_PERMISSIONS_URL = "team-members/me/permissions"; + +export const TEMPLATE_PERMISSIONS = [ + "createCompletionDocTemplates", + "editCompletionDocTemplates", + "deleteCompletionDocTemplates", +]; + +/** What the effective-permissions endpoint does: answer with keys, fail, or never settle. */ +export type PermissionsAnswer = string[] | Error | "pending"; + +export const permissionsAnswer: { current: PermissionsAnswer } = { + current: TEMPLATE_PERMISSIONS, +}; + +function answerPermissions(): Promise { + const answer = permissionsAnswer.current; + if (answer === "pending") return new Promise(() => undefined); + if (answer instanceof Error) return Promise.reject(answer); + return Promise.resolve({ permissions: answer }); +} + +/** + * Answers the template endpoints the way the backend contract describes, and the signed-in + * user's effective permissions with `permissionsAnswer.current`. + */ +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 === MY_PERMISSIONS_URL) return answerPermissions(); + 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..25580ff9 --- /dev/null +++ b/src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx @@ -0,0 +1,297 @@ +import { focusManager, QueryClient } from "@tanstack/react-query"; +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import CompletionDocsPage from "@/app/(protected)/completion-docs/index"; +import { renderWithProviders } from "@/test/test-utils"; +import { + BACKFLOW, + MY_PERMISSIONS_URL, + permissionsAnswer, + SCRUBBER, + serveTemplates, + TEMPLATE_PERMISSIONS, + 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(() => ({ id: "u1", userRoles: "Admin" })); + +vi.mock("@/api/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); + +vi.mock("@/providers/auth-context", async (importOriginal) => ({ + ...(await importOriginal()), + useAuthContext: () => ({ user: { id: auth.id, 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"); +} + +/** Lets the effective-permissions request finish (or stay pending) before asserting absence. */ +async function permissionsRequested() { + await waitFor(() => expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL)); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); +} + +beforeEach(() => { + user = userEvent.setup({ delay: null }); + auth.id = "u1"; + auth.userRoles = "Admin"; + permissionsAnswer.current = TEMPLATE_PERMISSIONS; + serveTemplates(api, [BACKFLOW, SCRUBBER]); +}); + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +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(); + }); +}); + +describe( + "Completion Docs actions follow the server's effective permissions", + { timeout: 15_000 }, + () => { + async function expectEveryAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + expect(await screen.findByRole("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(); + } + + async function expectNoAction() { + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + 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(); + } + + it("gives every template action to a user the server grants all three", async () => { + renderPage(); + + await expectEveryAction(); + expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL); + }); + + it("shows every action to a Dispatcher whose overrides grant them", async () => { + auth.userRoles = "Dispatcher"; + renderPage(); + + await expectEveryAction(); + }); + + it("hides Delete when the effective permissions omit it, even for an Admin", async () => { + permissionsAnswer.current = ["createCompletionDocTemplates", "editCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("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 Edit from a Scheduler whose edit permission was revoked", async () => { + auth.userRoles = "Scheduler"; + permissionsAnswer.current = ["createCompletionDocTemplates"]; + renderPage(); + const row = await screen.findByRole("row", { name: /Backflow Test Report/ }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + expect(within(row).queryByRole("button", { name: "Edit" })).not.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(); + }); + + it("hides add, edit and delete entirely from a user without those permissions", async () => { + auth.userRoles = "Dispatcher"; + permissionsAnswer.current = ["createWorkOrders"]; + serveTemplates(api, [templateDto()]); + renderPage(); + + await expectNoAction(); + }); + + it("hides every action while the permissions are still loading", async () => { + permissionsAnswer.current = "pending"; + renderPage(); + + await expectNoAction(); + }); + + it("hides every action when the permissions fail to load, and still lists templates", async () => { + permissionsAnswer.current = new Error("Network down"); + renderPage(); + + expect(await screen.findByRole("row", { name: /Scrubber Pit Augering/ })).toBeInTheDocument(); + await expectNoAction(); + }); + + it("hides the actions again when a later permissions refresh fails", async () => { + renderPage(); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + + permissionsAnswer.current = new Error("Network down"); + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + await waitFor(() => + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(), + ); + }); + + it("picks up a changed override when the window regains focus", async () => { + permissionsAnswer.current = []; + renderPage(); + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(); + + permissionsAnswer.current = ["createCompletionDocTemplates"]; + act(() => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + }); + + it("never reuses one signed-in user's permissions for the next", async () => { + // Keeps the first answer cached after unmount, so only the per-user key can keep it out. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + const first = renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument(); + first.unmount(); + + auth.id = "u2"; + permissionsAnswer.current = "pending"; + renderWithProviders(, { + route: "/completion-docs", + withAuth: false, + queryClient, + }); + + await screen.findByRole("row", { name: /Backflow Test Report/ }); + await permissionsRequested(); + expect(screen.queryByRole("button", { name: /Add Template/ })).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..8d45ebcf --- /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(await screen.findByRole("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/add-site-dialog.test.tsx b/src/test/app/(protected)/locations/add-site-dialog.test.tsx new file mode 100644 index 00000000..a9b6c328 --- /dev/null +++ b/src/test/app/(protected)/locations/add-site-dialog.test.tsx @@ -0,0 +1,207 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { HTTPError } from "ky"; +import { toast } from "react-toastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AddSiteDialog } from "@/app/(protected)/locations/_components/add-site-dialog"; +import { normalizeApiRequestError } from "@/api/api-error"; +import { formatStateOptionLabel, type StateCode } from "@/data/areas"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const create = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { create: (...args: unknown[]) => create(...args) }, +})); + +vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ + useAccountsList: () => ({ + data: { items: [{ id: 7, name: "Acme Logistics" }], totalCount: 1, page: 1, pageSize: 50 }, + isLoading: false, + }), +})); + +vi.mock("@/hooks/use-debounce", () => ({ + useDebounce: (value: string) => value, +})); + +function duplicateCodeError(): HTTPError { + const body = { + status: "Conflict", + message: "This site code already exists.", + code: "DuplicateSiteCode", + }; + const error = new HTTPError( + new Response(JSON.stringify(body), { status: 409 }), + new Request("https://api.example.test/api/locations"), + {} as never, + ); + error.data = body; + return normalizeApiRequestError(error) as HTTPError; +} + +function renderDialog() { + const onClose = vi.fn(); + renderWithProviders(, { withAuth: false }); + return { onClose, dialog: screen.getByRole("dialog", { name: /add site/i }) }; +} + +async function pickOption(label: string, optionName: string, search: string) { + const input = screen.getByRole("combobox", { name: label }); + input.focus(); + fireEvent.mouseDown(input); + fireEvent.change(input, { target: { value: search } }); + fireEvent.click(await screen.findByRole("option", { name: optionName })); +} + +async function fillRequiredFields(dialog: HTMLElement) { + fireEvent.change(within(dialog).getByRole("textbox", { name: "Site Code" }), { + target: { value: "dal1" }, + }); + await pickOption("Client", "Acme Logistics", "Acme"); + fireEvent.change(within(dialog).getByRole("combobox", { name: "Street Address" }), { + target: { value: "3811 Distribution Dr" }, + }); + fireEvent.change(within(dialog).getByRole("textbox", { name: "City" }), { + target: { value: "Dallas" }, + }); + await pickOption("State", "Texas (TX)", "texas"); + fireEvent.change(within(dialog).getByLabelText(/^Contact Name \(Main\)/), { + target: { value: "Jane" }, + }); + fireEvent.change(within(dialog).getByLabelText(/^Phone/), { + target: { value: "(421) 433-0032" }, + }); +} + +describe("AddSiteDialog", () => { + beforeEach(() => { + create.mockReset(); + }); + + it("blocks submission and names every missing required field", async () => { + const { dialog } = renderDialog(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + for (const message of [ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]) { + expect(await within(dialog).findByText(message)).toBeInTheDocument(); + } + expect(create).not.toHaveBeenCalled(); + }); + + it("shows the map placeholder until Street, City and State are filled, then the preview", async () => { + const { dialog } = renderDialog(); + + expect( + within(dialog).getByText("Enter an address to preview the location"), + ).toBeInTheDocument(); + await fillRequiredFields(dialog); + + const map = within(dialog).getByTitle("Site location map"); + expect(map).toHaveAttribute( + "src", + "https://www.google.com/maps?q=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX&output=embed", + ); + expect(within(dialog).getByText("Area: West")).toBeInTheDocument(); + }); + + it("suggests addresses from 3 typed characters and fills Street, City and State on pick", async () => { + const user = userEvent.setup(); + const { dialog } = renderDialog(); + const street = within(dialog).getByRole("combobox", { name: "Street Address" }); + + await user.type(street, "38"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + + await user.type(street, "1"); + const picked = suggestAddresses("381")[0]; + if (picked === undefined) throw new Error("expected a suggestion for 3 typed characters"); + const listbox = await screen.findByRole("listbox"); + await user.click(within(listbox).getByRole("option", { name: picked.label })); + + await waitFor(() => expect(street).toHaveValue("381")); + expect(within(dialog).getByRole("textbox", { name: "City" })).toHaveValue(picked.city); + expect(within(dialog).getByRole("combobox", { name: "State" })).toHaveValue( + formatStateOptionLabel(picked.state as StateCode), + ); + expect(within(dialog).getByTitle("Site location map")).toBeInTheDocument(); + }); + + it("keeps typed Street Address text when no suggestion is picked", async () => { + create.mockResolvedValue({}); + const user = userEvent.setup(); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + const street = within(dialog).getByRole("combobox", { name: "Street Address" }); + await user.clear(street); + await user.type(street, "12 Custom Ln"); + await user.keyboard("{Escape}"); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ address: "12 Custom Ln", city: "Dallas", state: "TX" }), + ); + }); + + it("creates the site with an upper-cased code, optional fields blank, then closes", async () => { + create.mockResolvedValue({}); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + name: "DAL1", + accountId: "7", + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + sitePhone: "", + contactEmail: "", + status: "Active", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }), + ); + }); + + it("keeps the modal open with an inline error when the Site Code already exists", async () => { + create.mockRejectedValue(duplicateCodeError()); + const toastError = vi.spyOn(toast, "error"); + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Add Site" })); + + expect(await within(dialog).findByText("This site code already exists.")).toBeInTheDocument(); + expect(within(dialog).getByRole("textbox", { name: "Site Code" })).toHaveAttribute( + "aria-invalid", + "true", + ); + expect(onClose).not.toHaveBeenCalled(); + expect(toastError).not.toHaveBeenCalled(); + }); + + it("Cancel closes without calling the API", async () => { + const { dialog, onClose } = renderDialog(); + + await fillRequiredFields(dialog); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx index 60444e29..fa6df17e 100644 --- a/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx +++ b/src/test/app/(protected)/locations/location-contacts-field-array.test.tsx @@ -37,6 +37,7 @@ function ContactsHarness({ state: "TX", zipCode: "70068", contactEmail: "jane@example.com", + accountId: "7", contacts: initialContacts, }, }); @@ -78,7 +79,7 @@ describe("LocationContactsFieldArray", () => { { withAuth: false }, ); - fireEvent.click(screen.getByRole("button", { name: /add contact/i })); + fireEvent.click(screen.getByRole("button", { name: /add point of contact/i })); expect(nameInputs()).toHaveLength(2); expect(screen.getByLabelText("Remove contact 1")).toBeEnabled(); diff --git a/src/test/app/(protected)/locations/location-form-state.test.tsx b/src/test/app/(protected)/locations/location-form-state.test.tsx deleted file mode 100644 index 23b716a3..00000000 --- a/src/test/app/(protected)/locations/location-form-state.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { screen } from "@testing-library/react"; -import { Route, Routes } from "react-router"; -import { describe, expect, it, vi } from "vitest"; -import LocationFormPage from "@/app/(protected)/locations/_components/location-form"; -import { renderWithProviders } from "@/test/test-utils"; - -const useLocationDetail = vi.fn(); -const useAccountsList = vi.fn(); -const useCreateLocation = vi.fn(); -const useUpdateLocation = vi.fn(); - -vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ - useLocationDetail: (...args: unknown[]) => useLocationDetail(...args), -})); - -vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({ - useAccountsList: (...args: unknown[]) => useAccountsList(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-create-location", () => ({ - useCreateLocation: (...args: unknown[]) => useCreateLocation(...args), -})); - -vi.mock("@/domain/locations/use-cases/use-update-location", () => ({ - useUpdateLocation: (...args: unknown[]) => useUpdateLocation(...args), -})); - -function mockHooks(location: Record | null) { - useLocationDetail.mockReturnValue({ - data: location, - isLoading: false, - error: null, - }); - useAccountsList.mockReturnValue({ - data: { items: [], totalCount: 0, page: 1, pageSize: 50 }, - isLoading: false, - }); - useCreateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); - useUpdateLocation.mockReturnValue({ mutate: vi.fn(), isPending: false }); -} - -function renderEditPage(locationId: string) { - return renderWithProviders( - - } /> - , - { - route: `/locations/edit/${locationId}`, - routerProps: { initialEntries: [`/locations/edit/${locationId}`] }, - withAuth: false, - }, - ); -} - -describe("LocationFormPage state field", () => { - it("normalizes legacy API state values for edit", () => { - mockHooks({ - id: "1", - name: "Dallas DC", - address: "3811 Distribution Dr", - city: "Dallas", - state: "texas", - zipCode: "70068", - phone: "(421) 433-0032", - contact: "Jane", - contactEmail: "jane@example.com", - status: "Active", - accountId: "7", - }); - - renderEditPage("1"); - - const stateInput = screen.getByLabelText(/^State/); - expect(stateInput).toHaveValue("Texas (TX)"); - expect(screen.getByText("Area: West")).toBeInTheDocument(); - }); - - it("normalizes code values and keeps their derived area", () => { - mockHooks({ - id: "2", - state: "IL", - status: "Active", - }); - - renderEditPage("2"); - - expect(screen.getByLabelText(/^State/)).toHaveValue("Illinois (IL)"); - expect(screen.getByText("Area: Central")).toBeInTheDocument(); - }); - - it("starts empty in create mode with no implicit IL default", () => { - mockHooks(null); - - renderWithProviders(, { - route: "/locations/new", - routerProps: { initialEntries: ["/locations/new"] }, - withAuth: false, - }); - - expect(screen.getByLabelText(/^State/)).toHaveValue(""); - expect(screen.queryByText(/Area: /)).not.toBeInTheDocument(); - }); -}); diff --git a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx index e0258626..a903447f 100644 --- a/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx +++ b/src/test/app/(protected)/locations/locations-contacts-cell.test.tsx @@ -8,6 +8,8 @@ function rowWithContacts(overrides: Partial): LocationListItem return { id: 1, name: "Dallas DC", + clientName: "Acme", + sitePhone: "", address: "3811 Distribution Dr", city: "Dallas", state: "TX", diff --git a/src/test/app/(protected)/locations/locations-list-area.test.tsx b/src/test/app/(protected)/locations/locations-list-area.test.tsx index fb45e28c..19b54ad8 100644 --- a/src/test/app/(protected)/locations/locations-list-area.test.tsx +++ b/src/test/app/(protected)/locations/locations-list-area.test.tsx @@ -125,7 +125,7 @@ describe("LocationsListPage area wiring", () => { fireEvent.mouseDown(screen.getByLabelText("Area")); fireEvent.click(screen.getByRole("option", { name: "West" })); - fireEvent.change(screen.getByPlaceholderText("Search locations..."), { + fireEvent.change(screen.getByPlaceholderText("Search sites..."), { target: { value: "dal" }, }); 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 new file mode 100644 index 00000000..8b5c2885 --- /dev/null +++ b/src/test/app/(protected)/locations/site-detail-panel.test.tsx @@ -0,0 +1,238 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import LocationsListPage from "@/app/(protected)/locations/index"; +import type { Location, LocationListItem } from "@/domain/locations/types/location"; +import { suggestAddresses } from "@/lib/address/vendor-address"; +import { renderWithProviders } from "@/test/test-utils"; + +const getList = vi.fn(); +const getById = vi.fn(); +const update = vi.fn(); + +vi.mock("@/domain/locations/api/locations-api", () => ({ + locationsApi: { + getList: (...args: unknown[]) => getList(...args), + getById: (...args: unknown[]) => getById(...args), + update: (...args: unknown[]) => update(...args), + create: vi.fn(), + delete: 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 dallas: Location = { + id: 1, + name: "DAL1", + clientName: "Acme Logistics", + accountId: 7, + address: "3811 Distribution Dr", + city: "Dallas", + state: "TX", + zipCode: "75201", + sitePhone: "(214) 555-0100", + contactEmail: "dal1@acme.test", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], +}; + +const chicago: Location = { + id: 2, + name: "CHI2", + clientName: "Globex", + accountId: 8, + address: "100 Main St", + city: "Chicago", + state: "IL", + contacts: [{ id: 41, name: "Carol", phone: "(312) 555-0100" }], +}; + +function listRow(location: Location): LocationListItem { + return { + id: location.id ?? "", + name: location.name ?? "", + clientName: location.clientName ?? "", + sitePhone: location.sitePhone ?? "", + address: location.address ?? "", + city: location.city ?? "", + state: location.state ?? "", + zipCode: location.zipCode ?? "", + phone: location.contacts?.[0]?.phone ?? "", + contact: location.contacts?.[0]?.name ?? "", + contactEmail: location.contactEmail ?? "", + status: "Active", + accountId: location.accountId ?? "", + contacts: location.contacts, + }; +} + +function panel(): HTMLElement { + return screen.getByRole("region", { name: "Site details" }); +} + +async function openSite(code: string): Promise { + fireEvent.click(await screen.findByRole("button", { name: code })); + const region = panel(); + await within(region).findByRole("heading", { name: code }); + return region; +} + +describe("Sites page detail panel", () => { + beforeEach(() => { + getList.mockReset(); + getById.mockReset(); + update.mockReset(); + getList.mockResolvedValue({ + items: [listRow(dallas), listRow(chicago)], + totalCount: 2, + page: 1, + pageSize: 12, + }); + getById.mockImplementation((id: string) => + Promise.resolve(String(id) === "2" ? chicago : dallas), + ); + }); + + it("opens a row in view mode with the address linked to Google Maps and a map preview", async () => { + renderWithProviders(, { withAuth: false }); + + const region = await openSite("DAL1"); + + expect(within(region).getAllByText("Acme Logistics").length).toBeGreaterThan(0); + const address = within(region).getByRole("link", { name: "3811 Distribution Dr" }); + expect(address).toHaveAttribute( + "href", + "https://www.google.com/maps/search/?api=1&query=3811%20Distribution%20Dr%2C%20Dallas%2C%20TX%2075201", + ); + expect(address).toHaveAttribute("target", "_blank"); + expect(within(region).getByTitle("Site location map")).toBeInTheDocument(); + expect(within(region).getByText("TX · Area: West")).toBeInTheDocument(); + expect(within(region).getByText("(214) 555-0100")).toBeInTheDocument(); + expect(within(region).getByText("dal1@acme.test")).toBeInTheDocument(); + expect(within(region).getByText(/Main: Jane/)).toBeInTheDocument(); + expect(within(region).getByText(/Contact 2: Bob/)).toBeInTheDocument(); + expect(within(region).queryByText(/status/i)).not.toBeInTheDocument(); + }); + + it("switches to edit in place, saves, and returns to view showing the saved values", async () => { + update.mockResolvedValue({}); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + const siteCode = within(region).getByRole("textbox", { name: "Site Code" }); + expect(siteCode).toHaveValue("DAL1"); + expect(siteCode).toBeDisabled(); + const save = within(region).getByRole("button", { name: "Save changes" }); + expect(save).toBeDisabled(); + + getById.mockResolvedValue({ ...dallas, city: "Fort Worth" }); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Fort Worth" }, + }); + await waitFor(() => expect(save).toBeEnabled()); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + name: "DAL1", + city: "Fort Worth", + sitePhone: "(214) 555-0100", + zipCode: "75201", + contacts: [ + { id: 31, name: "Jane", phone: "(421) 433-0032" }, + { id: 32, name: "Bob", phone: "(421) 433-0033" }, + ], + }), + ); + expect(await within(region).findByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Fort Worth")).toBeInTheDocument(); + }); + + it("edit mode: picking an address suggestion fills Street, City and State and enables Save", async () => { + update.mockResolvedValue({}); + getById.mockResolvedValue(dallas); + const user = userEvent.setup(); + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + const save = within(region).getByRole("button", { name: "Save changes" }); + + const street = within(region).getByRole("combobox", { name: "Street Address" }); + await user.clear(street); + await user.type(street, "415 Murphy Rd"); + const picked = suggestAddresses("415 Murphy Rd").find((option) => option.city !== "Dallas"); + if (picked === undefined) throw new Error("expected a non-Dallas suggestion"); + await user.click(await screen.findByRole("option", { name: picked.label })); + + await waitFor(() => expect(save).toBeEnabled()); + expect(within(region).getByRole("textbox", { name: "City" })).toHaveValue(picked.city); + fireEvent.click(save); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + expect(update).toHaveBeenCalledWith( + "1", + expect.objectContaining({ + address: "415 Murphy Rd", + city: picked.city, + state: picked.state, + }), + ); + }); + + it("Cancel discards edits and returns to view without an API call", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + fireEvent.change(within(region).getByRole("textbox", { name: "City" }), { + target: { value: "Houston" }, + }); + fireEvent.click(within(region).getByRole("button", { name: "Cancel" })); + + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getByText("Dallas")).toBeInTheDocument(); + expect(within(region).queryByText("Houston")).not.toBeInTheDocument(); + expect(update).not.toHaveBeenCalled(); + }); + + it("keeps the table usable: another row swaps the panel content in place", async () => { + renderWithProviders(, { withAuth: false }); + const region = await openSite("DAL1"); + fireEvent.click(within(region).getByRole("button", { name: "Edit" })); + + fireEvent.click(screen.getByRole("button", { name: "CHI2" })); + + await within(panel()).findByRole("heading", { name: "CHI2" }); + expect(panel()).toBe(region); + expect(within(region).getByRole("button", { name: "Edit" })).toBeInTheDocument(); + expect(within(region).getAllByText("Globex").length).toBeGreaterThan(0); + expect(within(region).queryByText("Acme Logistics")).not.toBeInTheDocument(); + }); + + it("Escape closes the panel", async () => { + renderWithProviders(, { withAuth: false }); + await openSite("DAL1"); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => + expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/test/app/(protected)/locations/state-select.test.tsx b/src/test/app/(protected)/locations/state-select.test.tsx index 68c3fa51..7ebf6040 100644 --- a/src/test/app/(protected)/locations/state-select.test.tsx +++ b/src/test/app/(protected)/locations/state-select.test.tsx @@ -29,7 +29,7 @@ function StateSelectHarness({ zipCode: "70068", contacts: [{ name: "Jane", phone: "(421) 433-0032" }], contactEmail: "jane@example.com", - status: "Active", + accountId: "7", }, }); 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/app/(protected)/workorders/overdue-work-order-type.test.tsx b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx new file mode 100644 index 00000000..5c429789 --- /dev/null +++ b/src/test/app/(protected)/workorders/overdue-work-order-type.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab"; +import { AdvancedSearchFilterSections } from "@/app/(protected)/workorders/_components/list/advanced-search/adv-filter-sections"; +import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-table-row-mapper"; +import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import { + EMPTY_ADVANCED_FILTERS, + type WorkOrderAdvancedFilters, +} from "@/domain/work-orders/types/work-order-advanced-filters"; +import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board"; +import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; +import { + canContinueWizardStep1, + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { getClosabilityGaps } from "@/domain/work-orders/utils/wo-closability"; + +const USERS = [{ id: "sarah", name: "Sarah Chen" }]; + +describe("Overdue work order type", () => { + it("offers Overdue in the Create WO wizard and never asks for a severity", () => { + const onPatch = vi.fn(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /No client response — due date passed/ })); + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ type: "Overdue", severity: null }), + ); + + const overdueDraft: WorkOrderWizardDraft = { ...EMPTY_WIZARD_DRAFT, type: "Overdue" }; + rerender( + , + ); + expect(screen.queryByText(/Severity \(from APM\)/)).not.toBeInTheDocument(); + expect(canContinueWizardStep1(overdueDraft)).toBe(true); + }); + + it("creates the work order with the Overdue enum and no severity", () => { + const payload = mapWizardDraftToBoardCreatePayload({ + ...EMPTY_WIZARD_DRAFT, + type: "Overdue", + severity: 2, + locationId: "1", + siteCode: "BK5", + }); + + expect(payload.workOrderType).toBe(WORK_ORDER_TYPE_API.Overdue); + expect(payload.severity ?? null).toBeNull(); + }); + + it("corrects a type inline to Overdue without opening the severity dialog", async () => { + const onSave = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Type of WO, Reactive" })); + await user.click(screen.getByRole("button", { name: /^Overdue$/ })); + + expect(onSave).toHaveBeenCalledWith("Overdue", null); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("reads the Overdue enum back from the board as the Overdue type", () => { + const row = mapWorkOrderTableRow({ id: 1, workOrderType: WORK_ORDER_TYPE_API.Overdue }); + expect(row.type).toBe("Overdue"); + }); + + it("closes with the completion document, not photos", () => { + const base = { + id: "1", + dispatcherId: "sarah", + dispatcherName: "Sarah Chen", + company: "Gateway Plumbing", + vendorId: "", + tech: "", + completedDate: "2026-09-24", + woNumber: "24971", + pm: "Backflow Preventers", + mediaCount: 0, + }; + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "No" as const }), + ).toEqual(["Completion document"]); + expect( + getClosabilityGaps({ ...base, type: "Overdue" as const, docStatus: "Yes" as const }), + ).toEqual([]); + + const row = { + id: "1", + type: "Overdue", + pm: "Backflow Preventers", + docStatus: "No", + rowVersion: "1", + }; + render( + , + ); + expect(screen.getByText("Completion Document")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open completion document/i })).toBeEnabled(); + }); + + it("keeps a separate Past Due status toggle in the advanced filters", () => { + let draft: WorkOrderAdvancedFilters = { ...EMPTY_ADVANCED_FILTERS }; + const setDraft = vi.fn( + (update: (prev: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters) => { + draft = update(draft); + }, + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /^Status/ })); + fireEvent.click(screen.getByRole("button", { name: /Past Due/ })); + + expect(draft.pastDue).toBe(true); + expect(draft.types.size).toBe(0); + expect(buildAdvancedFilterChips(draft)).toContainEqual( + expect.objectContaining({ group: "Status", value: "Past Due" }), + ); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx new file mode 100644 index 00000000..43a9a000 --- /dev/null +++ b/src/test/app/(protected)/workorders/work-order-ids-drilldown.test.tsx @@ -0,0 +1,172 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { describe, expect, it } from "vitest"; +import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; +import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; +import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; +import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params"; +import { + buildWorkOrderIdsHref, + WORK_ORDER_IDS_MAX, +} from "@/domain/work-orders/utils/work-order-ids-filter"; +import { renderWithProviders } from "@/test/test-utils"; + +/** The Work Orders page's filter wiring, without the table: URL → applied filters → board/search params. */ +function BoardFilterHarness() { + const filters = useWorkOrdersListFilters(); + useDashboardDrilldownFilters(filters.applyAdvancedFilters); + const location = useLocation(); + const params = buildResolvedBoardSearchParams({ + advApplied: filters.advApplied, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + return ( + <> + + {params == null ? "weekly board" : JSON.stringify(buildBoardSearchParamsRecord(params))} + + {location.search} + + + {filters.advApplied != null && ( + + )} + + ); +} + +function searchParams(): Record | "weekly board" { + const text = screen.getByRole("status", { name: "Board search params" }).textContent ?? ""; + return text === "weekly board" ? text : (JSON.parse(text) as Record); +} + +describe("Work Orders exact id filter", () => { + it("opens the board on exactly the linked work orders and shows the selection chip", async () => { + renderWithProviders(, { + route: "/workorders?ids=4,9,4", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("4,9"); + // No saved or default facet travels with the id set. + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + expect(params.types).toBeUndefined(); + + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + // The applied link params are consumed, like the dashboard drill-downs. + expect(screen.getByRole("status", { name: "Current search" })).toHaveTextContent(/^$/); + }); + + it("returns to the weekly board when the selection chip is cleared", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: buildWorkOrderIdsHref([12]), + withAuth: false, + }); + + expect(await screen.findByText("Showing 1 selected work order")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Remove filter" })); + + expect(searchParams()).toBe("weekly board"); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("replaces the selection when a panel filter is applied, so no chip shows a filter the server ignores", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply rescheduled only" })); + + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.rescheduled).toBe(true); + expect(screen.queryByText(/selected work order/)).not.toBeInTheDocument(); + }); + + it("keeps the selection when the panel is applied without a filter", async () => { + const user = userEvent.setup(); + renderWithProviders(, { + route: "/workorders?ids=4,9", + withAuth: false, + }); + + expect(await screen.findByText("Showing 2 selected work orders")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Apply unchanged" })); + + expect((searchParams() as Record).ids).toBe("4,9"); + expect(screen.getByText("Showing 2 selected work orders")).toBeInTheDocument(); + }); + + it("never sends more ids than the server accepts, however the selection was built", () => { + const ids = new Set(Array.from({ length: WORK_ORDER_IDS_MAX + 30 }, (_, index) => index + 1)); + const params = buildResolvedBoardSearchParams({ + advApplied: { ...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS), workOrderIds: ids }, + search: "", + page: 0, + pageSize: 200, + sortBy: "", + sortDir: "", + locations: [], + vendors: [], + }); + + expect(params?.ids?.split(",")).toHaveLength(WORK_ORDER_IDS_MAX); + }); + + it("ignores other drill-down params sent alongside the id set", async () => { + renderWithProviders(, { + route: "/workorders?ids=5&statuses=Scheduled&dispatchers=u1", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBe("5"); + expect(params.statuses).toBeUndefined(); + expect(params.dispatchers).toBeUndefined(); + }); + + it("keeps the regular drill-down filters when no ids are given", async () => { + renderWithProviders(, { + route: "/workorders?statuses=Scheduled", + withAuth: false, + }); + + await waitFor(() => expect(searchParams()).not.toBe("weekly board")); + const params = searchParams() as Record; + expect(params.ids).toBeUndefined(); + expect(params.statuses).toEqual([expect.any(Number)]); + }); +}); diff --git a/src/test/components/notifications/notification-center.test.tsx b/src/test/components/notifications/notification-center.test.tsx index 781ee098..8e169f44 100644 --- a/src/test/components/notifications/notification-center.test.tsx +++ b/src/test/components/notifications/notification-center.test.tsx @@ -9,8 +9,9 @@ import { NotificationSessionProvider } from "@/domain/notifications/session/noti import { renderWithProviders } from "@/test/test-utils"; const apiGet = vi.hoisted(() => vi.fn()); +const apiPostNoContent = vi.hoisted(() => vi.fn()); -vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("@/api/api", () => ({ apiGet, apiPostNoContent })); vi.mock("@/providers/auth-context", async (importOriginal) => ({ ...(await importOriginal()), @@ -131,6 +132,8 @@ describe("notification center", () => { beforeEach(() => { apiGet.mockReset(); apiGet.mockResolvedValue(standardFeed); + apiPostNoContent.mockReset(); + apiPostNoContent.mockResolvedValue(undefined); }); it("requests the signed-in user's feed and counts every unread item on the bell", async () => { @@ -256,6 +259,58 @@ describe("notification center", () => { expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); }); + it("keeps Clear all disabled when only rows that need acknowledging are left", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + + expect(within(panel).getByRole("button", { name: "Clear all" })).toBeDisabled(); + }); + + it("acknowledges a missed deadline through the API and removes the row", async () => { + apiGet.mockResolvedValue( + feed( + section("sla", "SLA at Risk", [acknowledgeRow]), + section("unassigned", "Unassigned", [unassigned]), + ), + ); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + const slaRow = within(panel).getByText(acknowledgeRow.title).closest("li") as HTMLElement; + const acknowledge = within(slaRow).getByRole("button", { name: "Acknowledge SLA breach" }); + expect(acknowledge).toHaveAttribute("title", "Acknowledge"); + expect(acknowledge).toHaveTextContent("Acknowledge"); + + apiGet.mockResolvedValue(feed(section("unassigned", "Unassigned", [unassigned]))); + await user.click(acknowledge); + + expect(apiPostNoContent).toHaveBeenCalledWith("notifications/sla/9/acknowledge"); + await waitFor(() => + expect(within(panel).queryByText(acknowledgeRow.title)).not.toBeInTheDocument(), + ); + expect(sectionLabels(panel)).toEqual(["Unassigned, 3"]); + }); + + it("keeps the row when the acknowledgement fails", async () => { + apiGet.mockResolvedValue(feed(section("sla", "SLA at Risk", [acknowledgeRow]))); + apiPostNoContent.mockRejectedValue(new Error("boom")); + const user = userEvent.setup(); + renderBell(); + + const panel = await openPanel(user); + await user.click(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })); + + await waitFor(() => expect(apiPostNoContent).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(within(panel).getByRole("button", { name: "Acknowledge SLA breach" })).toBeEnabled(), + ); + expect(within(panel).getByText(acknowledgeRow.title)).toBeInTheDocument(); + }); + it("opens the unassigned queue with the existing board filters and closes the panel", async () => { const user = userEvent.setup(); renderBell(); diff --git a/src/test/components/notifications/sla-at-risk-banner.test.tsx b/src/test/components/notifications/sla-at-risk-banner.test.tsx new file mode 100644 index 00000000..163b36b9 --- /dev/null +++ b/src/test/components/notifications/sla-at-risk-banner.test.tsx @@ -0,0 +1,148 @@ +import { act, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useLocation } from "react-router"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner"; +import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider"; +import { queryKeys } from "@/infra/query-key/query-key"; +import { renderWithProviders } from "@/test/test-utils"; + +const apiGet = vi.hoisted(() => vi.fn()); +const toastWarn = vi.hoisted(() => vi.fn()); + +vi.mock("@/api/api", () => ({ apiGet })); +vi.mock("react-toastify", () => ({ toast: { warn: toastWarn } })); + +const BANNER = "Work orders at risk of missing their response deadline"; + +function atRisk(id: number, number: string | null = String(1000 + id)) { + return { + id, + number, + severity: 1, + startedAt: "2026-09-18T10:00:00Z", + deadlineAt: "2026-09-18T12:00:00Z", + percentElapsed: 60, + }; +} + +function feed(...slaAtRisk: ReturnType[]) { + return { generatedAt: "2026-09-18T11:12:00Z", sections: [], slaAtRisk }; +} + +function LocationProbe() { + const location = useLocation(); + return {`${location.pathname}${location.search}`}; +} + +function renderBanner() { + return renderWithProviders( + + + + , + { withAuth: false }, + ); +} + +describe("SLA at-risk banner", () => { + beforeEach(() => { + apiGet.mockReset(); + toastWarn.mockReset(); + }); + + it("stays hidden while no work order is past half of its response window", async () => { + apiGet.mockResolvedValue(feed()); + renderBanner(); + + await waitFor(() => expect(apiGet).toHaveBeenCalledWith("notifications")); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + expect(toastWarn).not.toHaveBeenCalled(); + }); + + it("names the single work order at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101))); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + expect( + within(banner).getByRole("button", { + name: "WO #1101 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + }); + + it("collapses several work orders to one line and opens Work Orders on exactly those", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + const user = userEvent.setup(); + renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + const open = within(banner).getByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + expect(open).toHaveAttribute("title", "View in Work Orders"); + expect(within(banner).getAllByRole("button")).toHaveLength(2); + + await user.click(open); + + expect(screen.getByRole("status", { name: "Current location" })).toHaveTextContent( + "/workorders?ids=101,205,318", + ); + }); + + it("dismisses for this session only and returns when a new work order becomes at risk", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205))); + const user = userEvent.setup(); + const { queryClient, unmount } = renderBanner(); + + const banner = await screen.findByRole("region", { name: BANNER }); + await user.click(within(banner).getByRole("button", { name: "Dismiss SLA alert" })); + expect(screen.queryByRole("region", { name: BANNER })).not.toBeInTheDocument(); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + const returned = await screen.findByRole("region", { name: BANNER }); + expect( + within(returned).getByRole("button", { + name: "WO #1318 is at risk of missing its response deadline", + }), + ).toBeInTheDocument(); + + unmount(); + renderBanner(); + expect( + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }), + ).toBeInTheDocument(); + }); + + it("toasts each work order once per session, however often the feed polls", async () => { + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null))); + const { queryClient } = renderBanner(); + + await screen.findByRole("region", { name: BANNER }); + expect(toastWarn).toHaveBeenCalledTimes(2); + expect(toastWarn).toHaveBeenCalledWith("WO #1101 is at 50% of its SLA response window", { + toastId: "sla-at-risk-101", + }); + expect(toastWarn).toHaveBeenCalledWith("WO 205 is at 50% of its SLA response window", { + toastId: "sla-at-risk-205", + }); + + apiGet.mockResolvedValue(feed(atRisk(101), atRisk(205, null), atRisk(318))); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + await screen.findByRole("button", { + name: "3 work orders are at risk of missing their response deadline", + }); + await act(() => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() })); + + expect(toastWarn).toHaveBeenCalledTimes(3); + expect(toastWarn).toHaveBeenLastCalledWith("WO #1318 is at 50% of its SLA response window", { + toastId: "sla-at-risk-318", + }); + }); +}); 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..b1c9966a --- /dev/null +++ b/src/test/domain/completion-templates/completion-template-mapper.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +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" }], + }); + }); +}); diff --git a/src/test/domain/locations/mappers/location-mapper.test.ts b/src/test/domain/locations/mappers/location-mapper.test.ts index 52d27cc6..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", () => { @@ -56,16 +57,15 @@ describe("location contacts hydration", () => { }); }); -describe("mapLocationToBackend contacts payload", () => { - it("sends trimmed contacts in displayed order and derives legacy fields from the first", () => { +describe("mapLocationToBackend site payload", () => { + it("sends trimmed contacts in displayed order and names the first as the legacy contact", () => { const payload = mapLocationToBackend({ - name: "Dallas DC", + name: " dal1 ", address: "3811 Distribution Dr", city: "Dallas", state: "TX", zipCode: "70068", contactEmail: "jane@example.com", - status: "Active", contacts: [ { id: 41, name: " Jane ", phone: " (421) 433-0032 " }, { name: "Bob", phone: "(421) 433-0033" }, @@ -77,18 +77,87 @@ describe("mapLocationToBackend contacts payload", () => { { name: "Bob", phone: "(421) 433-0033" }, ]); expect(payload.contact).toBe("Jane"); - expect(payload.phone).toBe("(421) 433-0032"); + expect(payload.name).toBe("dal1"); }); - it("keeps legacy contact/phone untouched when no contacts are provided", () => { + it("sends the Site Phone as phone, never the main contact's phone", () => { const payload = mapLocationToBackend({ - name: "Legacy DC", - contact: "Legacy Person", - phone: "555-0199", + sitePhone: " (214) 555-0100 ", + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }); + const withoutSitePhone = mapLocationToBackend({ + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], }); - expect(payload.contact).toBe("Legacy Person"); - expect(payload.phone).toBe("555-0199"); - expect(payload.contacts).toEqual([]); + expect(payload.phone).toBe("(214) 555-0100"); + expect(withoutSitePhone.phone).toBe(""); + }); + + it("omits status, title and notes unless given, so an update keeps the stored values", () => { + const bare = mapLocationToBackend({ name: "DAL1", contacts: [] }); + const full = mapLocationToBackend({ + name: "DAL1", + status: "Active", + title: "Dallas DC", + notes: "Gate code 1234", + contacts: [], + }); + + expect(bare).not.toHaveProperty("status"); + expect(bare).not.toHaveProperty("title"); + expect(bare).not.toHaveProperty("notes"); + expect(full).toMatchObject({ status: "Active", title: "Dallas DC", notes: "Gate code 1234" }); + }); + + it("keeps the legacy contact name when the caller sends no contacts list", () => { + const addressOnly = mapLocationToBackend({ + name: "DAL1", + address: "3811 Distribution Dr", + contact: "Jane Doe", + }); + const withContacts = mapLocationToBackend({ + contact: "Jane Doe", + contacts: [{ name: "Bob", phone: "(421) 433-0033" }], + }); + + expect(addressOnly.contact).toBe("Jane Doe"); + expect(withContacts.contact).toBe("Bob"); + }); +}); + +describe("mapLocation site fields", () => { + it("reads client name, site phone, title and notes separately from the main contact", () => { + const detail = mapLocation({ + id: 9, + name: "DAL1", + title: "Dallas DC", + clientName: "Acme", + phone: "(214) 555-0100", + notes: "Gate code 1234", + contacts: [{ id: 31, name: "Carol", phone: "555-0143" }], + }); + const row = mapLocationListItem({ id: 9, accountName: "Acme", phone: "(214) 555-0100" }); + + expect(detail).toMatchObject({ + clientName: "Acme", + sitePhone: "(214) 555-0100", + title: "Dallas DC", + notes: "Gate code 1234", + phone: "555-0143", + }); + 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/locations/schemas/location-schema.test.ts b/src/test/domain/locations/schemas/location-schema.test.ts index ecf04b56..859a0592 100644 --- a/src/test/domain/locations/schemas/location-schema.test.ts +++ b/src/test/domain/locations/schemas/location-schema.test.ts @@ -7,7 +7,8 @@ import { const validBase: LocationFormValues = { ...locationDefaultValues, - name: "Dallas DC", + name: "DAL1", + accountId: "7", address: "3811 Distribution Dr", city: "Dallas", state: "TX", @@ -46,8 +47,8 @@ describe("locationSchema contacts", () => { expect(result.success).toBe(false); if (!result.success) { const messages = result.error.issues.map((issue) => issue.message); - expect(messages).toContain("Phone is required"); - expect(messages).toContain("Contact person is required"); + expect(messages).toContain("Contact phone is required"); + expect(messages).toContain("Contact name is required"); } }); @@ -63,3 +64,41 @@ describe("locationSchema contacts", () => { } }); }); + +describe("locationSchema site fields", () => { + const validSite: LocationFormValues = { + ...validBase, + contacts: [{ name: "Jane", phone: "(421) 433-0032" }], + }; + + function messagesFor(values: LocationFormValues): string[] { + const result = locationSchema.safeParse(values); + return result.success ? [] : result.error.issues.map((issue) => issue.message); + } + + it("requires Site Code, Client, Street Address, City, State and the first contact", () => { + const messages = messagesFor({ ...locationDefaultValues }); + + expect(messages).toEqual( + expect.arrayContaining([ + "Site Code is required", + "Client is required", + "Street Address is required", + "City is required", + "State is required", + "Contact name is required", + "Contact phone is required", + ]), + ); + }); + + it("keeps Site Phone, Site Email and zip optional but checks the email format", () => { + expect( + locationSchema.safeParse({ ...validSite, sitePhone: "", contactEmail: "", zipCode: "" }) + .success, + ).toBe(true); + expect(messagesFor({ ...validSite, contactEmail: "not-an-email" })).toContain( + "Invalid email address", + ); + }); +}); diff --git a/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts new file mode 100644 index 00000000..5349faa8 --- /dev/null +++ b/src/test/domain/notifications/use-acknowledge-sla-breach.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import type { + NotificationFeed, + NotificationItem, +} from "@/domain/notifications/types/notification-feed"; +import { withoutSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach"; + +function row( + overrides: Partial & Pick, +): NotificationItem { + return { + reason: "sla", + severity: "Critical", + rowType: "acknowledge", + title: "missed its response deadline", + count: 1, + triggeredAt: "2026-09-18T12:00:00Z", + target: { kind: "workOrder", workOrderId: 9, tab: "info" }, + workOrders: [], + ...overrides, + }; +} + +const breach9 = row({ id: "sla-breach-9" }); +const breach10 = row({ id: "sla-breach-10", target: { kind: "workOrder", workOrderId: 10 } }); +const atRisk9 = row({ id: "sla-at-risk-9", severity: "High", rowType: "dismissable" }); +const aveta9 = row({ id: "aveta-missing-9", reason: "aveta-missing", rowType: "dismissable" }); + +const feed: NotificationFeed = { + generatedAt: "2026-09-18T12:00:00Z", + slaAtRisk: [], + sections: [ + { + reason: "sla", + label: "SLA at Risk", + severity: "Critical", + count: 3, + items: [breach9, breach10, atRisk9], + }, + { + reason: "aveta-missing", + label: "Aveta Missing", + severity: "Medium", + count: 1, + items: [aveta9], + }, + ], +}; + +describe("withoutSlaBreach", () => { + it("drops only the acknowledged work order's breach row and lowers its section count", () => { + const next = withoutSlaBreach(feed, 9); + + expect(next.sections[0].items.map((item) => item.id)).toEqual([ + "sla-breach-10", + "sla-at-risk-9", + ]); + expect(next.sections[0].count).toBe(2); + expect(next.sections[1]).toBe(feed.sections[1]); + }); + + it("leaves the feed as it was when that work order has no breach row", () => { + const next = withoutSlaBreach(feed, 42); + + expect(next.sections).toEqual(feed.sections); + expect(next.sections[0]).toBe(feed.sections[0]); + }); +}); diff --git a/src/test/domain/services/api/services-api.test.ts b/src/test/domain/services/api/services-api.test.ts index be46e93b..7dc0b1b0 100644 --- a/src/test/domain/services/api/services-api.test.ts +++ b/src/test/domain/services/api/services-api.test.ts @@ -78,11 +78,14 @@ describe("servicesApi", () => { }); }); - it("does not request services for the unsupported Overdue type", async () => { - const services = await servicesApi.getActiveForWorkOrderType("Overdue"); + it("lists the PM catalog for the Overdue type", async () => { + vi.mocked(apiGet).mockResolvedValue([]); - expect(apiGet).not.toHaveBeenCalled(); - expect(services).toEqual([]); + await servicesApi.getActiveForWorkOrderType("Overdue"); + + expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, { + searchParams: { isActive: true, workOrderType: 2 }, + }); }); it("creates, updates, and deactivates through the registry routes", async () => { 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( diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index e5d0cd0e..b82d970c 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -36,7 +36,9 @@ describe("mapWizardTypeToApi", () => { expect(mapWizardTypeToApi("Reactive")).toBe(WORK_ORDER_TYPE_API.Reactive); - expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Other); + expect(mapWizardTypeToApi("Overdue")).toBe(WORK_ORDER_TYPE_API.Overdue); + + expect(WORK_ORDER_TYPE_API.Overdue).toBe(8); expect(mapWizardTypeToApi("")).toBeNull(); }); diff --git a/src/test/domain/work-orders/utils/board-query-params.test.ts b/src/test/domain/work-orders/utils/board-query-params.test.ts index 866de6f6..39fa0275 100644 --- a/src/test/domain/work-orders/utils/board-query-params.test.ts +++ b/src/test/domain/work-orders/utils/board-query-params.test.ts @@ -60,16 +60,16 @@ describe("toBoardQueryParams", () => { expect(params.overdue).toBeUndefined(); }); - it("maps Overdue bar filter to overdue=true without types", () => { + it("maps the Overdue bar filter to the Overdue type, not the past-due flag", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["Overdue"]) }, ""); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps Overdue with other types as a plain type union", () => { const params = toBoardQueryParams({ ...baseBar, types: new Set(["PM", "Overdue"]) }, ""); - expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); - expect(params.overdue).toBe(true); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM, WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); it("omits types when the set is empty", () => { @@ -222,22 +222,23 @@ describe("toBoardSearchQueryParams", () => { expect(buildBoardSearchParamsRecord(params)).not.toHaveProperty("unscheduledOnly"); }); - it("maps Overdue-only advanced filter to overdue=true without types", () => { + it("maps the Overdue type advanced filter to types without the past-due flag", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, types: new Set(["Overdue"]), }, }); - expect(params.overdue).toBe(true); - expect(params.types).toBeUndefined(); + expect(params.types).toEqual([WORK_ORDER_TYPE_API.Overdue]); + expect(params.overdue).toBeUndefined(); }); - it("maps Overdue with real types as OR (types + overdue)", () => { + it("maps the Past Due status toggle to overdue=true alongside types", () => { const params = toBoardSearchQueryParams({ filters: { ...EMPTY_ADVANCED_FILTERS, - types: new Set(["PM", "Overdue"]), + types: new Set(["PM"]), + pastDue: true, }, }); expect(params.types).toEqual([WORK_ORDER_TYPE_API.PM]); diff --git a/src/test/domain/work-orders/utils/table-filter-utils.test.ts b/src/test/domain/work-orders/utils/table-filter-utils.test.ts index cb7b10e5..9798afb8 100644 --- a/src/test/domain/work-orders/utils/table-filter-utils.test.ts +++ b/src/test/domain/work-orders/utils/table-filter-utils.test.ts @@ -172,26 +172,26 @@ describe("filterByBarAndSearch", () => { expect(ids).toEqual(["em", "rx"]); }); - it("matches Overdue via isPastDue even when row.type is not Overdue", () => { + it("matches the Overdue type only, never a past-due row of another type", () => { const pastDuePm = makeRow({ id: "pd", type: "PM", isPastDue: true, scheduledOn: "2026-06-03", }); - const currentPm = makeRow({ - id: "now", - type: "PM", + const overdue = makeRow({ + id: "ov", + type: "Overdue", isPastDue: false, scheduledOn: "2026-06-03", }); const ids = filterByBarAndSearch( - [pastDuePm, currentPm], + [pastDuePm, overdue], bar(weekMonday, { types: new Set(["Overdue"]) }), "", ).map((row) => row.id); - expect(ids).toEqual(["pd"]); + expect(ids).toEqual(["ov"]); }); it("hides a row after type correction when the bar filter no longer matches", () => { @@ -395,13 +395,8 @@ describe("matchesAdvancedFilters", () => { expect(matchesAdvancedFilters(row, noMatch)).toBe(false); }); - it("matches Overdue when isPastDue is true", () => { + it("matches the Overdue type filter by type, independent of isPastDue", () => { const weekMonday = mondayOf(todayIso()); - const pastDue = makeRow({ - scheduledOn: weekMonday, - ...deriveDayFields(weekMonday), - isPastDue: true, - }); const filters = { ...EMPTY_ADVANCED_FILTERS, dateRange: "custom" as const, @@ -409,9 +404,39 @@ describe("matchesAdvancedFilters", () => { customTo: "9999-12-31", types: new Set(["Overdue" as const]), }; - expect(matchesAdvancedFilters(pastDue, filters)).toBe(true); + const overdue = makeRow({ type: "Overdue", isPastDue: false, scheduledOn: weekMonday }); + const pastDuePm = makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }); + expect(matchesAdvancedFilters(overdue, filters)).toBe(true); + expect(matchesAdvancedFilters(pastDuePm, filters)).toBe(false); + }); + + it("narrows to past-due rows with the Past Due status toggle", () => { + const weekMonday = mondayOf(todayIso()); + const filters = { + ...EMPTY_ADVANCED_FILTERS, + dateRange: "custom" as const, + customFrom: "0000-01-01", + customTo: "9999-12-31", + types: new Set(["PM" as const]), + pastDue: true, + }; expect( - matchesAdvancedFilters(makeRow({ isPastDue: false, scheduledOn: weekMonday }), filters), + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), + ).toBe(true); + expect( + matchesAdvancedFilters( + makeRow({ type: "PM", isPastDue: false, scheduledOn: weekMonday }), + filters, + ), + ).toBe(false); + expect( + matchesAdvancedFilters( + makeRow({ type: "Overdue", isPastDue: true, scheduledOn: weekMonday }), + filters, + ), ).toBe(false); }); diff --git a/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts new file mode 100644 index 00000000..638f9609 --- /dev/null +++ b/src/test/domain/work-orders/utils/work-order-ids-filter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + WORK_ORDER_IDS_MAX, + buildWorkOrderIdsHref, + normalizeWorkOrderIds, + parseWorkOrderIdsParam, +} from "@/domain/work-orders/utils/work-order-ids-filter"; + +describe("work-order ids filter", () => { + it("builds the Work Orders link for an exact id set", () => { + expect(buildWorkOrderIdsHref([101, 205, 318])).toBe("/workorders?ids=101,205,318"); + }); + + it("drops duplicates and non-positive ids from the link, and links the plain board when none remain", () => { + expect(buildWorkOrderIdsHref([205, 101, 205, 0, -4, 1.5])).toBe("/workorders?ids=205,101"); + expect(buildWorkOrderIdsHref([])).toBe("/workorders"); + }); + + it("reads the ids param, ignoring malformed tokens", () => { + expect(parseWorkOrderIdsParam(" 7, 3 ,abc,,7,-2,0,4.5,12 ")).toEqual([7, 3, 12]); + expect(parseWorkOrderIdsParam(null)).toEqual([]); + expect(parseWorkOrderIdsParam("")).toEqual([]); + }); + + it("keeps at most the backend limit of distinct ids", () => { + const ids = Array.from({ length: WORK_ORDER_IDS_MAX + 20 }, (_, index) => index + 1); + const normalized = normalizeWorkOrderIds([...ids, 1, 2]); + + expect(normalized).toHaveLength(WORK_ORDER_IDS_MAX); + expect(normalized[0]).toBe(1); + expect(normalized[normalized.length - 1]).toBe(WORK_ORDER_IDS_MAX); + }); +});