diff --git a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx index a9ed0377..0705ac47 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Wrench } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; @@ -32,6 +32,85 @@ type ServiceNotesDialogProps = { onSave: (details: ServiceNotesDetails) => void; }; +type DialogTarget = { + key: string; + pm: string; + serviceId?: string | number | null; +}; + +function getDialogTarget( + woNumber: string | undefined, + serviceId: string | number | null | undefined, + pm: string, +) { + const key = woNumber ? `wo:${woNumber}` : serviceId != null ? `service:${serviceId}` : `pm:${pm}`; + return { key, pm, serviceId }; +} + +function selectedServices(pm: string, extraServices: string[]) { + return [pm, ...extraServices].filter( + (value, index, values) => !!value && values.indexOf(value) === index, + ); +} + +function useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, +}: { + open: boolean; + target: DialogTarget; + pm: string; + serviceId: string | number | null | undefined; + extraServices: string[]; + serviceNotes: string; + avetaRequired: boolean; +}) { + const [sel, setSel] = useState([]); + const [note, setNote] = useState(""); + const [aveta, setAveta] = useState(false); + const [search, setSearch] = useState(""); + const lastTarget = useRef(null); + const { key: targetKey, pm: targetPm, serviceId: targetServiceId } = target; + + useEffect(() => { + if (!open) { + lastTarget.current = null; + return; + } + const previous = lastTarget.current; + if (previous == null || previous.key !== targetKey) { + setSel(selectedServices(pm, extraServices)); + setNote(serviceNotes ?? ""); + setAveta(Boolean(avetaRequired)); + setSearch(""); + } else if ( + previous.serviceId != null && + String(previous.serviceId) === String(serviceId) && + previous.pm !== pm + ) { + setSel((current) => (current[0] === previous.pm ? [pm, ...current.slice(1)] : current)); + } + lastTarget.current = { key: targetKey, pm: targetPm, serviceId: targetServiceId }; + }, [ + open, + targetKey, + targetPm, + targetServiceId, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + ]); + + return { sel, setSel, note, setNote, aveta, setAveta, search, setSearch }; +} + export function ServiceNotesDialog({ open, onOpenChange, @@ -47,20 +126,16 @@ export function ServiceNotesDialog({ viewOnly = false, onSave, }: ServiceNotesDialogProps) { - const [sel, setSel] = useState([]); - const [note, setNote] = useState(""); - const [aveta, setAveta] = useState(false); - const [search, setSearch] = useState(""); - - useEffect(() => { - if (!open) { - return; - } - setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i)); - setNote(serviceNotes ?? ""); - setAveta(Boolean(avetaRequired)); - setSearch(""); - }, [open, pm, extraServices, serviceNotes, avetaRequired]); + const target = getDialogTarget(woNumber, serviceId, pm); + const { sel, setSel, note, setNote, aveta, setAveta, search, setSearch } = useServiceNotesDraft({ + open, + target, + pm, + serviceId, + extraServices, + serviceNotes, + avetaRequired, + }); const toggle = (value: string) => { setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value])); diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx index fd692c6c..9a77af28 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table.tsx @@ -171,7 +171,7 @@ function WoTableRenderedInlineRow({ ); } -function WoTableEmptyResult({ +function getWoTableEmptyResult({ tableData, onOpenAdvancedFilters, onClearSearch, @@ -303,14 +303,12 @@ export function WoTable({ ); } - const emptyResult = ( - - ); - if (emptyResult != null) return emptyResult; + const emptyResult = getWoTableEmptyResult({ + tableData, + onOpenAdvancedFilters, + onClearSearch, + }); + if (emptyResult) return emptyResult; return ( <> diff --git a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx index ef8ef567..86b66331 100644 --- a/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx +++ b/src/test/app/(protected)/workorders/aveta-service-notes.test.tsx @@ -1,12 +1,73 @@ import { useState } from "react"; -import { fireEvent, screen } from "@testing-library/react"; +import { fireEvent, screen, waitForElementToBeRemoved } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell"; import { ServiceNotesDialog } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; +import type { ServiceNotesDetails } from "@/app/(protected)/workorders/_components/list/table/cells/service-notes-dialog"; import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell"; import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch"; +import type { Service } from "@/domain/services/types/service"; import { renderWithProviders } from "@/test/test-utils"; +function serviceDefinition(id: number, name: string): Service { + return { + id, + name, + trade: "Electrical", + iconKey: "wrench", + requiresCompletionDocument: false, + completionDocTemplate: null, + isActive: true, + supportedWorkOrderTypes: ["PM"], + }; +} + +function ServiceRegistryRefreshHarness({ + withDetails, + onSaved, +}: { + withDetails: boolean; + onSaved: (details: ServiceNotesDetails) => void; +}) { + const [renamed, setRenamed] = useState(false); + const [saved, setSaved] = useState({ + pm: "Original service", + serviceId: 701, + extraServices: [], + serviceNotes: withDetails ? "Existing notes" : "", + avetaRequired: false, + }); + const primaryName = renamed ? "Renamed service" : "Original service"; + + return ( + <> + setRenamed(true), + }} + onSave={vi.fn()} + onSaveDetails={(details) => { + onSaved(details); + setSaved(details); + }} + /> + + ); +} + describe("Aveta Service & Notes", () => { it("defers linked type selection with visible loading and retry feedback", () => { const retry = vi.fn(); @@ -495,4 +556,107 @@ describe("Aveta Service & Notes", () => { expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }), ); }); + + it.each([ + ["picker Add service and notes", false], + ["direct details trigger", true], + ])("retains the draft and linked ID across a rename from the %s", async (_entry, withDetails) => { + const onSaved = vi.fn(); + renderWithProviders( + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /Original service/i })); + if (!withDetails) { + fireEvent.click(screen.getByRole("button", { name: /Add service and notes/i })); + } + const notes = screen.getByPlaceholderText(/describe the specific issue/i); + fireEvent.change(notes, { target: { value: "Typed while registry refreshed" } }); + fireEvent.click(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })); + fireEvent.click(screen.getByRole("button", { name: "Extra service" })); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(notes).toHaveValue("Typed while registry refreshed"); + expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked(); + expect(screen.getByRole("button", { name: /Renamed servicePrimary/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSaved).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + pm: "Renamed service", + serviceId: 701, + extraServices: ["Extra service"], + serviceNotes: "Typed while registry refreshed", + avetaRequired: true, + }), + ); + + await waitForElementToBeRemoved(() => screen.queryByRole("dialog")); + fireEvent.click(screen.getByRole("button", { name: /Renamed service/i })); + expect(screen.getByPlaceholderText(/describe the specific issue/i)).toHaveValue( + "Typed while registry refreshed", + ); + expect(screen.getByRole("checkbox", { name: /Requires Aveta documentation/i })).toBeChecked(); + expect(screen.getByText("Primary").closest("button")).toHaveTextContent("Renamed service"); + expect(screen.getByRole("button", { name: "Extra service" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + expect(onSaved).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + serviceId: 701, + extraServices: ["Extra service"], + serviceNotes: "Typed while registry refreshed", + avetaRequired: true, + }), + ); + }); + + it("preserves a deliberate primary selection during linked-service rename", () => { + const onSave = vi.fn(); + const common = { + open: true, + onOpenChange: vi.fn(), + extraServices: [], + serviceNotes: "Saved notes", + avetaRequired: false, + pmTypes: [], + services: [ + serviceDefinition(701, "Original service"), + serviceDefinition(702, "Other service"), + ], + serviceId: 701, + serviceQuery: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + onSave, + }; + const view = renderWithProviders(, { + withAuth: false, + }); + + fireEvent.click(screen.getByRole("button", { name: /Original servicePrimary/i })); + fireEvent.click(screen.getByRole("button", { name: "Other service" })); + view.rerender( + , + ); + expect(screen.getByRole("button", { name: /Other servicePrimary/i })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ pm: "Other service", serviceId: 702 }), + ); + }); }); diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts index 73007eea..f9b11d28 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-cancel-toast.test.ts @@ -52,6 +52,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => }), })); +vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({ + useWorkOrderServiceRegistry: () => ({ + services: [], + serviceHistory: [], + servicePickerState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryReadyForTypeChange: true, + }), +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ useWorkOrderTableMutations: () => ({ patchField, diff --git a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts index 60c004c7..7e2de77c 100644 --- a/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts +++ b/src/test/app/(protected)/workorders/use-work-orders-list-page-completion-doc.test.ts @@ -51,6 +51,28 @@ vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => }), })); +vi.mock("@/app/(protected)/workorders/_hooks/use-work-order-service-registry", () => ({ + useWorkOrderServiceRegistry: () => ({ + services: [], + serviceHistory: [], + servicePickerState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryState: { + isLoading: false, + isError: false, + isFetching: false, + hasData: true, + retry: vi.fn(), + }, + serviceHistoryReadyForTypeChange: true, + }), +})); + vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({ useWorkOrderTableMutations: () => ({ patchField, diff --git a/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx b/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx index 496fa5d5..b9fd3c11 100644 --- a/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx +++ b/src/test/app/(protected)/workorders/wo-table-adv-search-pagination.test.tsx @@ -128,6 +128,28 @@ function tableData( } describe("WoTable advanced-search pagination", () => { + it("renders the nonempty table body and pager", () => { + renderWithProviders( + , + { withAuth: false }, + ); + + expect(screen.getByText("WO-0")).toBeInTheDocument(); + expect(screen.getByText("RESULTS ยท 1778 items")).toBeInTheDocument(); + expect(screen.getByText("Page 1 of 36")).toBeInTheDocument(); + }); + it("pages the flat Unassigned search without a pin or a 1778-row dump", () => { const goToNextDayPage = vi.fn(); const data = tableData({ goToNextDayPage });