diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index e6298c3f..e7ee92cb 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts index 0816018f..547723a6 100644 --- a/e2e/work-orders/work-orders.visual.spec.ts +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -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: [] }); }); } diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-account-field.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-account-field.ts new file mode 100644 index 00000000..4664c494 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-account-field.ts @@ -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); + }, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts new file mode 100644 index 00000000..1b0a4f30 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -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); + }, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index afd70e08..5259e328 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -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), }; } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx index ad53384d..ede43f70 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx @@ -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) => { diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx index 6f01be6a..36a4e4ba 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -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; } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select.tsx index 5ab5a3e1..b3e7e444 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select.tsx @@ -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 (
@@ -32,8 +38,9 @@ export function WizardStepLocationAccountSelect({ } searchPlaceholder="Search account name…" - options={options} + options={accountOptions(names, selected)} onPick={onCustomerChange} + onSearchChange={setSearch} /> ({ - 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( + , + { 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" }); + }); }); diff --git a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts index fe70bd74..17c488f0 100644 --- a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -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", () => {