fix(work-orders): address review and CI for account on create

Search accounts on the server and show the missing-account wizard hint.
Split site-dialog state so the maintainability line cap passes.
This commit is contained in:
Arthur Bassi 2026-08-25 18:46:10 -03:00
parent 1714da6e37
commit 800671030f
12 changed files with 354 additions and 167 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View file

@ -82,6 +82,10 @@ function isBoardDetail(pathname: string) {
return /\/workorders\/\d+\/detail$/.test(pathname);
}
function isAccountList(pathname: string) {
return pathname.includes("/Account/GetAccountList");
}
async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") {
await page.clock.setFixedTime(new Date(FROZEN_NOW));
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
@ -161,6 +165,27 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error"
return;
}
if (isAccountList(pathname)) {
await fulfillJson(route, {
items: [
{
id: 1,
name: "Amazon",
number: "AMZ-1",
industry: "Retail",
website: "",
primaryContact: "",
phoneNumber: "",
owner: "",
},
],
totalCount: 1,
page: 1,
pageSize: 50,
});
return;
}
await fulfillJson(route, { data: [] });
});
}

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
type UseSiteDialogAccountFieldArgs = {
open: boolean;
customer: string;
createMode: boolean;
dirty: boolean;
onDirty: () => void;
};
export function useSiteDialogAccountField({
open,
customer,
createMode,
dirty,
onDirty,
}: UseSiteDialogAccountFieldArgs) {
const [accountName, setAccountNameState] = useState(customer);
useEffect(() => {
if (!open || dirty) {
return;
}
setAccountNameState(customer);
}, [open, dirty, customer]);
return {
accountName,
accountMissing: createMode && !accountName.trim(),
customerPatch: createMode ? { customer: accountName } : {},
setAccountName: (next: string) => {
onDirty();
setAccountNameState(next);
},
};
}

View file

@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
type UseSiteDialogFormFieldsArgs = {
open: boolean;
value: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
additionalContacts: WorkOrderAdditionalContact[];
sites: LocationOption[];
createMode: boolean;
};
export function useSiteDialogFormFields({
open,
value,
locationId,
pocName,
pocPhone,
pocNotes,
additionalContacts,
sites,
createMode,
}: UseSiteDialogFormFieldsArgs) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [extraContacts, setExtraContacts] = useState(additionalContacts);
const [pocFilledFor, setPocFilledFor] = useState("");
const [dirty, setDirty] = useState(false);
const [contactsDirty, setContactsDirty] = useState(false);
const [baselineHadContacts, setBaselineHadContacts] = useState(
() => additionalContacts.length > 0,
);
const [showErrors, setShowErrors] = useState(false);
useEffect(() => {
if (!open) {
setDirty(false);
setContactsDirty(false);
setShowErrors(false);
}
}, [open]);
useEffect(() => {
if (!open || dirty) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setExtraContacts(additionalContacts);
setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
const siteChanged = id !== locId;
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (siteChanged) {
setExtraContacts([]);
setContactsDirty(true);
setDirty(true);
if (createMode) {
setPn("");
setPp("");
}
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const markPocDirty = (apply: () => void) => {
setDirty(true);
apply();
};
return {
code,
locId,
pn,
pp,
notes,
extraContacts,
pocFilledFor,
dirty,
contactsDirty,
baselineHadContacts,
showErrors,
selected: sites.find((s) => String(s.id) === locId),
siteMissing: !locId,
setPocFilledFor,
setPn,
setPp,
setShowErrors,
markDirty: () => setDirty(true),
handlePick,
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
setPpDirty: (next: string) => markPocDirty(() => setPp(next)),
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
setDirty(true);
setContactsDirty(true);
setExtraContacts(contacts);
},
};
}

View file

@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useSiteDialogAccountField } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-account-field";
import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -39,69 +39,36 @@ export function useSiteDialogState({
createMode,
onSave,
}: UseSiteDialogStateArgs) {
const [code, setCode] = useState(value);
const [accountName, setAccountName] = useState(customer);
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [extraContacts, setExtraContacts] = useState(additionalContacts);
const [pocFilledFor, setPocFilledFor] = useState("");
const [dirty, setDirty] = useState(false);
const [contactsDirty, setContactsDirty] = useState(false);
const [baselineHadContacts, setBaselineHadContacts] = useState(
() => additionalContacts.length > 0,
);
const [showErrors, setShowErrors] = useState(false);
const fields = useSiteDialogFormFields({
open,
value,
locationId,
pocName,
pocPhone,
pocNotes,
additionalContacts,
sites,
createMode,
});
const account = useSiteDialogAccountField({
open,
customer,
createMode,
dirty: fields.dirty,
onDirty: fields.markDirty,
});
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
const { accountMissing } = account;
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(open && locId ? locId : undefined);
const selected = sites.find((s) => String(s.id) === locId);
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const siteMissing = !locId;
const accountMissing = createMode && !accountName.trim();
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || (!pocMissing && !accountMissing));
useEffect(() => {
if (!open) {
setDirty(false);
setContactsDirty(false);
setShowErrors(false);
}
}, [open]);
useEffect(() => {
if (!open || dirty) {
return;
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setAccountName(customer);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setExtraContacts(additionalContacts);
setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [
open,
dirty,
value,
locationId,
customer,
pocName,
pocPhone,
pocNotes,
additionalContacts,
sites,
]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
@ -109,89 +76,53 @@ export function useSiteDialogState({
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
const siteChanged = id !== locId;
setLocId(id);
if (next) {
setCode(resolveSiteCode(next));
}
if (siteChanged) {
setExtraContacts([]);
setContactsDirty(true);
setDirty(true);
if (createMode) {
setPn("");
setPp("");
}
setPocFilledFor("");
} else if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
const buildPatch = (): SitePatch =>
buildSiteDialogPatch({
code,
locId,
selected,
...(createMode ? { customer: accountName } : {}),
pocName: pn,
pocPhone: pp,
pocNotes: notes,
extraContacts,
contactsDirty,
baselineHadContacts,
});
const markPocDirty = (apply: () => void) => {
setDirty(true);
apply();
};
const markContactsDirty = (contacts: WorkOrderAdditionalContact[]) => {
setDirty(true);
setContactsDirty(true);
setExtraContacts(contacts);
};
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor]);
const attemptSave = () => {
if (createMode && !canConfirm) {
setShowErrors(true);
fields.setShowErrors(true);
return;
}
onSave(buildPatch());
onSave(
buildSiteDialogPatch({
code: fields.code,
locId: fields.locId,
selected: fields.selected,
...account.customerPatch,
pocName: fields.pn,
pocPhone: fields.pp,
pocNotes: fields.notes,
extraContacts: fields.extraContacts,
contactsDirty: fields.contactsDirty,
baselineHadContacts: fields.baselineHadContacts,
}),
);
onOpenChange(false);
};
return {
locId,
code,
accountName,
accountMissing,
selected,
locId: fields.locId,
code: fields.code,
accountName: account.accountName,
accountMissing: account.accountMissing,
selected: fields.selected,
locationDetail,
locationDetailLoading,
locationDetailError,
addressPreview,
pn,
pp,
notes,
extraContacts,
showErrors,
siteMissing,
handlePick,
setAccountName: (next: string) => {
setDirty(true);
setAccountName(next);
},
pn: fields.pn,
pp: fields.pp,
notes: fields.notes,
extraContacts: fields.extraContacts,
showErrors: fields.showErrors,
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
setAccountName: account.setAccountName,
attemptSave,
setPn: (next: string) => markPocDirty(() => setPn(next)),
setPp: (next: string) => markPocDirty(() => setPp(next)),
setNotes: (next: string) => markPocDirty(() => setNotes(next)),
setExtraContacts: markContactsDirty,
setPn: fields.setPnDirty,
setPp: fields.setPpDirty,
setNotes: fields.setNotesDirty,
setExtraContacts: fields.setExtraContactsDirty,
close: () => onOpenChange(false),
};
}

View file

@ -17,6 +17,7 @@ type WizardFieldSelectProps = {
search?: boolean;
searchPlaceholder?: string;
disabled?: boolean;
onSearchChange?: (query: string) => void;
};
function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] {
@ -41,15 +42,22 @@ export function WizardFieldSelect({
search = true,
searchPlaceholder = "Search…",
disabled = false,
onSearchChange,
}: WizardFieldSelectProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [q, setQ] = useState("");
const selected = options.find((o) => o.key === value);
const filtered = filterOptions(options, q);
const filtered = onSearchChange ? options : filterOptions(options, q);
const handleQueryChange = (next: string) => {
setQ(next);
onSearchChange?.(next);
};
const handleClose = () => {
close();
setQ("");
onSearchChange?.("");
};
return (
@ -117,7 +125,7 @@ export function WizardFieldSelect({
search={search}
searchPlaceholder={searchPlaceholder}
query={q}
onQueryChange={setQ}
onQueryChange={handleQueryChange}
options={filtered}
value={value}
onPick={(key) => {

View file

@ -3,10 +3,10 @@ import { Button } from "@/components/ui/button";
import {
WIZARD_STEPS,
canContinueWizardStep1,
canContinueWizardStep2,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
wizardStep2ContinueHint,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
@ -28,9 +28,8 @@ function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
}
return "Select severity to continue";
}
if (step === 2 && !canContinueWizardStep2(draft)) {
if (!draft.locationId) return "Pick a site to continue";
return "Enter POC name and phone to continue";
if (step === 2) {
return wizardStep2ContinueHint(draft);
}
return null;
}

View file

@ -1,8 +1,10 @@
import { useState } from "react";
import { Building2, Info } from "lucide-react";
import { Text } from "@/components/ui/text";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { useDebounce } from "@/hooks/use-debounce";
type WizardStepLocationAccountSelectProps = {
customer: string;
@ -10,17 +12,21 @@ type WizardStepLocationAccountSelectProps = {
showError?: boolean;
};
function accountOptions(names: string[], selected: string) {
const withSelected = selected && !names.includes(selected) ? [selected, ...names] : names;
return withSelected.map((name) => ({ key: name, label: name }));
}
export function WizardStepLocationAccountSelect({
customer,
onCustomerChange,
showError = false,
}: WizardStepLocationAccountSelectProps) {
const { data } = useAccountsList({ page: 1, pageSize: 100, search: "" });
const accounts = data?.items ?? [];
const options = accounts
.map((account) => account.name.trim())
.filter(Boolean)
.map((name) => ({ key: name, label: name }));
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data } = useAccountsList({ page: 1, pageSize: 50, search: debouncedSearch });
const names = (data?.items ?? []).map((account) => account.name.trim()).filter(Boolean);
const selected = customer.trim();
return (
<div>
@ -32,8 +38,9 @@ export function WizardStepLocationAccountSelect({
<Building2 size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search account name…"
options={options}
options={accountOptions(names, selected)}
onPick={onCustomerChange}
onSearchChange={setSearch}
/>
<Text
as="p"

View file

@ -107,7 +107,7 @@ export interface WorkOrderBoardCreatePayload {
/** Per-WO Aveta compliance flag from Service & Notes. */
avetaRequired?: boolean;
locationId?: number;
/** Exact `Accounts.Name` for org-wide create stamp (SH-221). */
/** Exact `Accounts.Name` for org-wide create stamp. */
customer?: string;
pocContactId?: number;
pocName?: string;

View file

@ -202,6 +202,19 @@ export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
return Boolean(hasSite && draft.customer.trim() && draft.pocName.trim() && draft.pocPhone.trim());
}
export function wizardStep2ContinueHint(draft: WorkOrderWizardDraft): string | null {
if (canContinueWizardStep2(draft)) {
return null;
}
if (!draft.locationId) {
return "Pick a site to continue";
}
if (!draft.customer.trim()) {
return "Select an account to continue";
}
return "Enter POC name and phone to continue";
}
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
}

View file

@ -1,38 +1,50 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
useAccountsList: () => ({
data: {
items: [
{
id: 1,
name: "Amazon",
number: "",
industry: "",
website: "",
primaryContact: "",
phoneNumber: "",
owner: "",
},
{
id: 2,
name: "Sea Haven",
number: "",
industry: "",
website: "",
primaryContact: "",
phoneNumber: "",
owner: "",
},
],
},
}),
const { useAccountsListMock } = vi.hoisted(() => ({
useAccountsListMock: vi.fn(),
}));
vi.mock("@/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
useAccountsList: (params: unknown) => useAccountsListMock(params),
}));
const ACCOUNTS = {
items: [
{
id: 1,
name: "Amazon",
number: "",
industry: "",
website: "",
primaryContact: "",
phoneNumber: "",
owner: "",
},
{
id: 2,
name: "Sea Haven",
number: "",
industry: "",
website: "",
primaryContact: "",
phoneNumber: "",
owner: "",
},
],
};
describe("WizardStepLocationAccountSelect", () => {
beforeEach(() => {
useAccountsListMock.mockReturnValue({ data: ACCOUNTS });
});
it("sets draft.customer to the exact account name on pick", () => {
const onCustomerChange = vi.fn();
renderWithProviders(
@ -45,4 +57,20 @@ describe("WizardStepLocationAccountSelect", () => {
expect(onCustomerChange).toHaveBeenCalledWith("Amazon");
});
it("passes typed search to the accounts list query", () => {
renderWithProviders(
<WizardStepLocationAccountSelect customer="" onCustomerChange={vi.fn()} />,
{ withAuth: false },
);
expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" });
fireEvent.click(screen.getByRole("button", { name: /select account/i }));
fireEvent.change(screen.getByPlaceholderText("Search account name…"), {
target: { value: "Sea" },
});
expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "Sea" });
});
});

View file

@ -7,6 +7,7 @@ import {
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
wizardStep2ContinueHint,
} from "@/domain/work-orders/types/work-order-wizard";
describe("mapWizardDraftToFormValues", () => {
@ -135,6 +136,22 @@ describe("wizard step validation", () => {
};
expect(canContinueWizardStep2(draft)).toBe(false);
expect(isWizardStepValid(2, draft)).toBe(false);
expect(wizardStep2ContinueHint(draft)).toBe("Select an account to continue");
});
it("hints pick a site when step 2 has no location", () => {
expect(wizardStep2ContinueHint(EMPTY_WIZARD_DRAFT)).toBe("Pick a site to continue");
});
it("hints POC when step 2 has site and account but no phone", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
locationId: "99",
siteCode: "BK5",
customer: "Amazon",
pocName: "Jordan",
};
expect(wizardStep2ContinueHint(draft)).toBe("Enter POC name and phone to continue");
});
it("allows step 2 when site, account, and POC are set", () => {
@ -150,6 +167,7 @@ describe("wizard step validation", () => {
};
expect(canContinueWizardStep2(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
expect(wizardStep2ContinueHint(draft)).toBeNull();
});
it("blocks create without siteCode", () => {