fix(workorders): preserve table and service drafts

This commit is contained in:
Alexandre Brandizzi 2026-09-30 19:22:33 -03:00
parent 48fc415d54
commit 3b58bceb3d
6 changed files with 328 additions and 25 deletions

View file

@ -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]));

View file

@ -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 (
<>

View file

@ -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 }),
);
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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 });