mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 21:42:06 +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 { Wrench } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields";
|
||||||
|
|
@ -32,6 +32,85 @@ type ServiceNotesDialogProps = {
|
||||||
onSave: (details: ServiceNotesDetails) => void;
|
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({
|
export function ServiceNotesDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -47,20 +126,16 @@ export function ServiceNotesDialog({
|
||||||
viewOnly = false,
|
viewOnly = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: ServiceNotesDialogProps) {
|
}: ServiceNotesDialogProps) {
|
||||||
const [sel, setSel] = useState<string[]>([]);
|
const target = getDialogTarget(woNumber, serviceId, pm);
|
||||||
const [note, setNote] = useState("");
|
const { sel, setSel, note, setNote, aveta, setAveta, search, setSearch } = useServiceNotesDraft({
|
||||||
const [aveta, setAveta] = useState(false);
|
open,
|
||||||
const [search, setSearch] = useState("");
|
target,
|
||||||
|
pm,
|
||||||
useEffect(() => {
|
serviceId,
|
||||||
if (!open) {
|
extraServices,
|
||||||
return;
|
serviceNotes,
|
||||||
}
|
avetaRequired,
|
||||||
setSel([pm, ...(extraServices ?? [])].filter((p, i, a) => !!p && a.indexOf(p) === i));
|
});
|
||||||
setNote(serviceNotes ?? "");
|
|
||||||
setAveta(Boolean(avetaRequired));
|
|
||||||
setSearch("");
|
|
||||||
}, [open, pm, extraServices, serviceNotes, avetaRequired]);
|
|
||||||
|
|
||||||
const toggle = (value: string) => {
|
const toggle = (value: string) => {
|
||||||
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
setSel((cur) => (cur.includes(value) ? cur.filter((x) => x !== value) : [...cur, value]));
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ function WoTableRenderedInlineRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WoTableEmptyResult({
|
function getWoTableEmptyResult({
|
||||||
tableData,
|
tableData,
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
|
|
@ -303,14 +303,12 @@ export function WoTable({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyResult = (
|
const emptyResult = getWoTableEmptyResult({
|
||||||
<WoTableEmptyResult
|
tableData,
|
||||||
tableData={tableData}
|
onOpenAdvancedFilters,
|
||||||
onOpenAdvancedFilters={onOpenAdvancedFilters}
|
onClearSearch,
|
||||||
onClearSearch={onClearSearch}
|
});
|
||||||
/>
|
if (emptyResult) return emptyResult;
|
||||||
);
|
|
||||||
if (emptyResult != null) return emptyResult;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,73 @@
|
||||||
import { useState } from "react";
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
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 { 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 { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||||
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
import { serviceIdentityPatch } from "@/domain/work-orders/utils/service-identity-patch";
|
||||||
|
import type { Service } from "@/domain/services/types/service";
|
||||||
import { renderWithProviders } from "@/test/test-utils";
|
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", () => {
|
describe("Aveta Service & Notes", () => {
|
||||||
it("defers linked type selection with visible loading and retry feedback", () => {
|
it("defers linked type selection with visible loading and retry feedback", () => {
|
||||||
const retry = vi.fn();
|
const retry = vi.fn();
|
||||||
|
|
@ -495,4 +556,107 @@ describe("Aveta Service & Notes", () => {
|
||||||
expect.objectContaining({ pm: "QA Test Service", serviceId: 702 }),
|
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", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||||
useWorkOrderTableMutations: () => ({
|
useWorkOrderTableMutations: () => ({
|
||||||
patchField,
|
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", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||||
useWorkOrderTableMutations: () => ({
|
useWorkOrderTableMutations: () => ({
|
||||||
patchField,
|
patchField,
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,28 @@ function tableData(
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("WoTable advanced-search pagination", () => {
|
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", () => {
|
it("pages the flat Unassigned search without a pin or a 1778-row dump", () => {
|
||||||
const goToNextDayPage = vi.fn();
|
const goToNextDayPage = vi.fn();
|
||||||
const data = tableData({ goToNextDayPage });
|
const data = tableData({ goToNextDayPage });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue