fix: send customer on board work-order create

Org-wide Admin create needs exact Accounts.Name so SH-221 can stamp AccountId.
This commit is contained in:
Arthur Bassi 2026-08-25 18:19:06 -03:00
parent b9b250ba46
commit 1714da6e37
22 changed files with 237 additions and 10 deletions

View file

@ -19,6 +19,7 @@ export function buildInlineDraftCreateInput(
locationId: String(draft.locationId || ""),
locationName,
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
customer: draft.customer.trim(),
woNumber: draft.woNumber,
type: draft.type || "PM",
severity: draft.severity,

View file

@ -8,6 +8,7 @@ type BuildSiteDialogPatchArgs = {
code: string;
locId: string;
selected: LocationOption | undefined;
customer?: string;
pocName: string;
pocPhone: string;
pocNotes: string;
@ -20,6 +21,7 @@ export function buildSiteDialogPatch({
code,
locId,
selected,
customer,
pocName,
pocPhone,
pocNotes,
@ -31,6 +33,7 @@ export function buildSiteDialogPatch({
site: code || (selected ? resolveSiteCode(selected) : ""),
location: selected ? extractLocationLabel(selected) : "",
locationId: locId,
...(customer !== undefined ? { customer } : {}),
pocName,
pocPhone,
pocNotes,

View file

@ -11,6 +11,7 @@ type SiteCellProps = {
value: string;
location: string;
locationId: string | number;
customer?: string;
city?: string;
state?: string;
pocName?: string;
@ -29,6 +30,7 @@ export function SiteCell({
value,
location,
locationId,
customer,
city,
state,
pocName,
@ -95,6 +97,7 @@ export function SiteCell({
onOpenChange={setOpen}
value={value}
locationId={locationId}
customer={customer}
pocName={pocName}
pocPhone={pocPhone}
pocNotes={pocNotes}

View file

@ -4,6 +4,7 @@ export type SitePatch = {
site: string;
location: string;
locationId: string | number;
customer?: string;
pocName: string;
pocPhone: string;
pocNotes: string;

View file

@ -9,6 +9,7 @@ import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
import { SiteDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-footer";
import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select";
import { SiteDialogPocSection } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-section";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
@ -20,6 +21,7 @@ type SiteDialogProps = {
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
customer?: string;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
@ -35,6 +37,7 @@ export function SiteDialog({
onOpenChange,
value,
locationId,
customer = "",
pocName = "",
pocPhone = "",
pocNotes = "",
@ -49,6 +52,7 @@ export function SiteDialog({
onOpenChange,
value,
locationId,
customer,
pocName,
pocPhone,
pocNotes,
@ -81,6 +85,13 @@ export function SiteDialog({
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{createMode && (
<WizardStepLocationAccountSelect
customer={s.accountName}
showError={s.showErrors && s.accountMissing}
onCustomerChange={s.setAccountName}
/>
)}
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect

View file

@ -15,6 +15,7 @@ type UseSiteDialogStateArgs = {
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
customer?: string;
pocName: string;
pocPhone: string;
pocNotes: string;
@ -29,6 +30,7 @@ export function useSiteDialogState({
onOpenChange,
value,
locationId,
customer = "",
pocName,
pocPhone,
pocNotes,
@ -38,6 +40,7 @@ export function useSiteDialogState({
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);
@ -60,8 +63,9 @@ export function useSiteDialogState({
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);
const canConfirm = !siteMissing && (!createMode || (!pocMissing && !accountMissing));
useEffect(() => {
if (!open) {
@ -77,6 +81,7 @@ export function useSiteDialogState({
}
const resolved = resolveLocationId(locationId, value, sites);
setCode(value);
setAccountName(customer);
setLocId(resolved);
setPn(pocName);
setPp(pocPhone);
@ -84,7 +89,18 @@ export function useSiteDialogState({
setExtraContacts(additionalContacts);
setBaselineHadContacts(additionalContacts.length > 0);
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
}, [
open,
dirty,
value,
locationId,
customer,
pocName,
pocPhone,
pocNotes,
additionalContacts,
sites,
]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
@ -121,6 +137,7 @@ export function useSiteDialogState({
code,
locId,
selected,
...(createMode ? { customer: accountName } : {}),
pocName: pn,
pocPhone: pp,
pocNotes: notes,
@ -152,6 +169,8 @@ export function useSiteDialogState({
return {
locId,
code,
accountName,
accountMissing,
selected,
locationDetail,
locationDetailLoading,
@ -164,6 +183,10 @@ export function useSiteDialogState({
showErrors,
siteMissing,
handlePick,
setAccountName: (next: string) => {
setDirty(true);
setAccountName(next);
},
attemptSave,
setPn: (next: string) => markPocDirty(() => setPn(next)),
setPp: (next: string) => markPocDirty(() => setPp(next)),

View file

@ -50,6 +50,7 @@ export function WoTableInlineIdentityCells({
value={draft.site}
location={draft.location}
locationId={draft.locationId}
customer={draft.customer}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
@ -63,6 +64,7 @@ export function WoTableInlineIdentityCells({
site: p.site,
location: p.location,
locationId: p.locationId,
...(p.customer !== undefined ? { customer: p.customer } : {}),
pocName: p.pocName,
pocPhone: p.pocPhone,
pocNotes: p.pocNotes,

View file

@ -78,7 +78,9 @@ export function WoTableInlineRow({
const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null);
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
const siteCellMissing = missing.some(
(f) => f === "customer" || f === "site" || f === "pocName" || f === "pocPhone",
);
const siteShowError = showErrors && siteCellMissing;
const woShowError = showErrors && missing.includes("woNumber");

View file

@ -46,6 +46,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
["Schedule on", scheduleOnLabel(draft)],
["Due date", fmtDate(draft.dueDate) || "—"],
["Assigned to", draft.assigneeName || "—"],
["Account", draft.customer.trim() || "—"],
["Site", draft.siteCode || draft.locationName || "—"],
["POC", pocLabel(draft)],
["Additional POCs", formatAdditionalPocSummary(draft)],

View file

@ -0,0 +1,49 @@
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";
type WizardStepLocationAccountSelectProps = {
customer: string;
onCustomerChange: (customer: string) => void;
showError?: boolean;
};
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 }));
return (
<div>
<WizLabel required>Account</WizLabel>
<WizardFieldSelect
value={customer}
placeholder="Select account…"
icon={
<Building2 size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search account name…"
options={options}
onPick={onCustomerChange}
/>
<Text
as="p"
variant="description"
className="mt-1.5 flex items-center gap-1"
sx={{ fontSize: 11, color: showError ? "var(--destructive)" : "var(--muted-foreground)" }}
when={!customer.trim()}
>
<Info size={12} className="shrink-0" /> Account is required to create a work order.
</Text>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select";
import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
@ -44,6 +45,11 @@ export function WizardStepLocationService({
return (
<div className="space-y-5">
<WizardStepLocationAccountSelect
customer={draft.customer}
showError={showErrors}
onCustomerChange={(customer) => onPatch({ customer })}
/>
<WizardStepLocationSiteSelect
locationId={draft.locationId}
locations={locations}

View file

@ -141,5 +141,10 @@ export function mapWizardDraftToBoardCreatePayload(
payload.locationId = locationId;
}
const customer = draft.customer.trim();
if (customer) {
payload.customer = customer;
}
return payload;
}

View file

@ -107,6 +107,8 @@ 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). */
customer?: string;
pocContactId?: number;
pocName?: string;
pocPhone?: string;

View file

@ -79,6 +79,8 @@ export interface WorkOrderTableRow {
export interface WorkOrderTableDraft {
site: string;
/** Exact CRM `Accounts.Name` sent as board create `customer`. */
customer: string;
woNumber: string;
type: WOType | "";
severity: WorkOrderSeverity | null;
@ -111,6 +113,7 @@ export interface WorkOrderTableDraft {
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
customer: "",
woNumber: "",
type: "",
severity: null,

View file

@ -28,6 +28,8 @@ export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
siteCode: string;
/** Exact CRM `Accounts.Name` sent as board create `customer`. */
customer: string;
woNumber: string;
type: WOType | "";
/** Severity 1–5 for Emergency/Reactive; null otherwise. */
@ -74,6 +76,7 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
locationId: "",
locationName: "",
siteCode: "",
customer: "",
woNumber: "",
type: "",
severity: null,
@ -196,7 +199,7 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
return Boolean(hasSite && draft.customer.trim() && draft.pocName.trim() && draft.pocPhone.trim());
}
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {

View file

@ -1,7 +1,8 @@
/** Required fields for board inline create (Schedule On is optional — SH-109). */
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
export type InlineCreateReqField = "customer" | "site" | "woNumber" | "pocName" | "pocPhone";
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
customer: "Account",
site: "Site",
woNumber: "WO #",
pocName: "POC name",
@ -9,6 +10,7 @@ export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
};
export type InlineCreateDraftFields = {
customer?: string;
site?: string;
locationId?: string | number;
woNumber?: string;
@ -18,6 +20,9 @@ export type InlineCreateDraftFields = {
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
const miss: InlineCreateReqField[] = [];
if (!d.customer?.trim()) {
miss.push("customer");
}
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
if (!hasSite) {
miss.push("site");
@ -43,6 +48,9 @@ export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
}
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
if (miss.includes("customer")) {
return "Account is required";
}
if (miss.includes("site")) {
return "Site is required";
}

View file

@ -8,6 +8,7 @@ describe("buildInlineDraftCreateInput", () => {
const draft = {
...EMPTY_TABLE_DRAFT,
site: "BK5",
customer: "Amazon",
locationId: "9",
woNumber: "25001",
pocName: "Jane",
@ -30,6 +31,8 @@ describe("buildInlineDraftCreateInput", () => {
expect(payload.scheduleWeekOnly).toBeUndefined();
expect(payload.woNumber).toBe("25001");
expect(payload.pocName).toBe("Jane");
expect(payload.customer).toBe("Amazon");
expect(input.customer).toBe("Amazon");
});
it("keeps week-only schedule when targetWeek is set", () => {

View file

@ -0,0 +1,48 @@
import { fireEvent, screen } from "@testing-library/react";
import { 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: "",
},
],
},
}),
}));
describe("WizardStepLocationAccountSelect", () => {
it("sets draft.customer to the exact account name on pick", () => {
const onCustomerChange = vi.fn();
renderWithProviders(
<WizardStepLocationAccountSelect customer="" onCustomerChange={onCustomerChange} />,
{ withAuth: false },
);
fireEvent.click(screen.getByRole("button", { name: /select account/i }));
fireEvent.click(screen.getByRole("button", { name: "Amazon" }));
expect(onCustomerChange).toHaveBeenCalledWith("Amazon");
});
});

View file

@ -114,6 +114,7 @@ describe("workOrdersApi.createFromBoard", () => {
type: "PM",
siteCode: "BK5",
woNumber: "12345",
customer: "Amazon",
});
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
@ -122,6 +123,7 @@ describe("workOrdersApi.createFromBoard", () => {
lifecycleStatus: 1,
woNumber: "12345",
isAddOn: false,
customer: "Amazon",
});
});
});

View file

@ -123,7 +123,7 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows step 2 when site and POC are set", () => {
it("blocks step 2 without customer", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
@ -133,6 +133,21 @@ describe("wizard step validation", () => {
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canContinueWizardStep2(draft)).toBe(false);
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows step 2 when site, account, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345",
locationId: "99",
siteCode: "BK5",
customer: "Amazon",
pocName: "Jordan",
pocPhone: "555-0100",
};
expect(canContinueWizardStep2(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
@ -148,13 +163,14 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows create when type, woNumber, site, and POC are set", () => {
it("allows create when type, woNumber, site, account, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901234567890",
locationId: "99",
siteCode: "BK5",
customer: "Amazon",
pocName: "Jordan",
pocPhone: "555-0100",
};

View file

@ -60,6 +60,28 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
});
});
it("includes customer when the draft has an account name", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
customer: " Amazon ",
});
expect(payload.customer).toBe("Amazon");
});
it("omits customer when the draft account is blank", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
customer: " ",
});
expect(payload.customer).toBeUndefined();
});
it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,

View file

@ -7,9 +7,10 @@ import {
} from "@/domain/work-orders/utils/get-inline-create-missing";
describe("getInlineCreateMissing", () => {
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
it("returns empty when Account, Site, WO#, and POC are filled even without schedule", () => {
expect(
getInlineCreateMissing({
customer: "Amazon",
site: "BK5",
locationId: "12",
woNumber: "25001",
@ -22,6 +23,7 @@ describe("getInlineCreateMissing", () => {
it("accepts locationId alone as site presence", () => {
expect(
getInlineCreateMissing({
customer: "Amazon",
site: "",
locationId: 42,
woNumber: "25001",
@ -34,6 +36,7 @@ describe("getInlineCreateMissing", () => {
it("lists missing POC fields without requiring schedule", () => {
expect(
getInlineCreateMissing({
customer: "Amazon",
site: "BK5",
woNumber: "25001",
pocName: "",
@ -42,8 +45,14 @@ describe("getInlineCreateMissing", () => {
).toEqual(["pocName", "pocPhone"]);
});
it("lists site and woNumber when empty", () => {
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
it("lists account, site, and woNumber when empty", () => {
expect(getInlineCreateMissing({})).toEqual([
"customer",
"site",
"woNumber",
"pocName",
"pocPhone",
]);
});
});
@ -58,6 +67,10 @@ describe("inlineCreateSaveHint", () => {
});
describe("inlineCreateSiteError", () => {
it("prefers account-required message when account is missing", () => {
expect(inlineCreateSiteError(["customer", "site"])).toBe("Account is required");
});
it("prefers site-required message when site is missing", () => {
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
});