mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-01 22:07:53 +00:00
fix(workorders): preserve table and service drafts
This commit is contained in:
parent
48fc415d54
commit
3b58bceb3d
6 changed files with 328 additions and 25 deletions
|
|
@ -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<string[]>([]);
|
||||
const [note, setNote] = useState("");
|
||||
const [aveta, setAveta] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const lastTarget = useRef<DialogTarget | null>(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<string[]>([]);
|
||||
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]));
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ function WoTableRenderedInlineRow({
|
|||
);
|
||||
}
|
||||
|
||||
function WoTableEmptyResult({
|
||||
function getWoTableEmptyResult({
|
||||
tableData,
|
||||
onOpenAdvancedFilters,
|
||||
onClearSearch,
|
||||
|
|
@ -303,14 +303,12 @@ export function WoTable({
|
|||
);
|
||||
}
|
||||
|
||||
const emptyResult = (
|
||||
<WoTableEmptyResult
|
||||
tableData={tableData}
|
||||
onOpenAdvancedFilters={onOpenAdvancedFilters}
|
||||
onClearSearch={onClearSearch}
|
||||
/>
|
||||
);
|
||||
if (emptyResult != null) return emptyResult;
|
||||
const emptyResult = getWoTableEmptyResult({
|
||||
tableData,
|
||||
onOpenAdvancedFilters,
|
||||
onClearSearch,
|
||||
});
|
||||
if (emptyResult) return emptyResult;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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<ServiceNotesDetails>({
|
||||
pm: "Original service",
|
||||
serviceId: 701,
|
||||
extraServices: [],
|
||||
serviceNotes: withDetails ? "Existing notes" : "",
|
||||
avetaRequired: false,
|
||||
});
|
||||
const primaryName = renamed ? "Renamed service" : "Original service";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PMTypeCell
|
||||
value={saved.pm}
|
||||
pmTypes={[]}
|
||||
services={[serviceDefinition(701, primaryName), serviceDefinition(702, "Extra service")]}
|
||||
serviceId={saved.serviceId}
|
||||
workOrderType="PM"
|
||||
serviceNotes={saved.serviceNotes}
|
||||
extraServices={saved.extraServices}
|
||||
avetaRequired={saved.avetaRequired}
|
||||
woNumber="WO-701"
|
||||
serviceQuery={{
|
||||
isLoading: false,
|
||||
isError: !renamed,
|
||||
isFetching: false,
|
||||
hasData: true,
|
||||
retry: () => 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(
|
||||
<ServiceRegistryRefreshHarness withDetails={withDetails} onSaved={onSaved} />,
|
||||
{ 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(<ServiceNotesDialog {...common} pm="Original service" />, {
|
||||
withAuth: false,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Original servicePrimary/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Other service" }));
|
||||
view.rerender(
|
||||
<ServiceNotesDialog
|
||||
{...common}
|
||||
pm="Renamed service"
|
||||
services={[
|
||||
serviceDefinition(701, "Renamed service"),
|
||||
serviceDefinition(702, "Other service"),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -128,6 +128,28 @@ function tableData(
|
|||
}
|
||||
|
||||
describe("WoTable advanced-search pagination", () => {
|
||||
it("renders the nonempty table body and pager", () => {
|
||||
renderWithProviders(
|
||||
<WoTable
|
||||
tableData={tableData()}
|
||||
users={[]}
|
||||
sites={[]}
|
||||
vendors={[]}
|
||||
pmTypes={[]}
|
||||
weekMonday="2026-05-18"
|
||||
onNavigateToDay={vi.fn()}
|
||||
onOpenSlideOver={vi.fn()}
|
||||
onOpenMedia={vi.fn()}
|
||||
onPatch={vi.fn()}
|
||||
/>,
|
||||
{ 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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue