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/build-inline-draft-create-input.ts b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts index 5f911bd8..169f062e 100644 --- a/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts +++ b/src/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input.ts @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts index 3fe930cd..1b9e23c3 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch.ts @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx index 4c40ae06..04077833 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -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} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts index 499af6aa..956d54ba 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog-types.ts @@ -4,6 +4,7 @@ export type SitePatch = { site: string; location: string; locationId: string | number; + customer?: string; pocName: string; pocPhone: string; pocNotes: string; diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx index 0843e331..e68dc5c7 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent } from "react"; +import { useContext, type MouseEvent } from "react"; import { MapPin } from "lucide-react"; import { Dialog, DialogContent, DialogTitle } from "@mui/material"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; @@ -9,9 +9,12 @@ 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"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; export type { SitePatch }; @@ -20,6 +23,7 @@ type SiteDialogProps = { onOpenChange: (open: boolean) => void; value: string; locationId: string | number; + customer?: string; pocName?: string; pocPhone?: string; pocNotes?: string; @@ -35,6 +39,7 @@ export function SiteDialog({ onOpenChange, value, locationId, + customer = "", pocName = "", pocPhone = "", pocNotes = "", @@ -44,17 +49,20 @@ export function SiteDialog({ viewOnly = false, onSave, }: SiteDialogProps) { + const requireAccount = isOrgWideScope(useContext(AuthContext)?.user); const s = useSiteDialogState({ open, onOpenChange, value, locationId, + customer, pocName, pocPhone, pocNotes, additionalContacts, sites, createMode, + requireAccount, onSave, }); @@ -81,6 +89,13 @@ export function SiteDialog({
+ {Boolean(createMode && requireAccount) && ( + + )}
Site void; +}; + +export function useSiteDialogAccountField({ + open, + customer, + createMode, + requireAccount = true, + dirty, + onDirty, +}: UseSiteDialogAccountFieldArgs) { + const [accountName, setAccountNameState] = useState(customer); + + useEffect(() => { + if (!open || dirty) { + return; + } + setAccountNameState(customer); + }, [open, dirty, customer]); + + return { + accountName, + accountMissing: createMode && requireAccount && !accountName.trim(), + customerPatch: createMode && requireAccount ? { 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 32df0645..ca321241 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"; @@ -15,12 +15,14 @@ type UseSiteDialogStateArgs = { onOpenChange: (open: boolean) => void; value: string; locationId: string | number; + customer?: string; pocName: string; pocPhone: string; pocNotes: string; additionalContacts: WorkOrderAdditionalContact[]; sites: LocationOption[]; createMode: boolean; + requireAccount?: boolean; onSave: (patch: SitePatch) => void; }; @@ -29,62 +31,46 @@ export function useSiteDialogState({ onOpenChange, value, locationId, + customer = "", pocName, pocPhone, pocNotes, additionalContacts = [], sites, createMode, + requireAccount = true, onSave, }: UseSiteDialogStateArgs) { - 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); - + const fields = useSiteDialogFormFields({ + open, + value, + locationId, + pocName, + pocPhone, + pocNotes, + additionalContacts, + sites, + createMode, + }); + const account = useSiteDialogAccountField({ + open, + customer, + createMode, + requireAccount, + 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 pocMissing = !pn.trim() || !pp.trim(); - const canConfirm = !siteMissing && (!createMode || !pocMissing); - - 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 canConfirm = !siteMissing && (!createMode || (!pocMissing && !accountMissing)); useEffect(() => { if (!open || !locId || !locationDetail || pocFilledFor === locId) { @@ -93,82 +79,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, - 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, - 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, + 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/list/table/wo-table-inline-identity-cells.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx index 47ca7b46..f650a040 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-identity-cells.tsx @@ -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, diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 0e29c529..9a0d3a8a 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type RefObject } from "react"; +import { useContext, useRef, useState, type RefObject } from "react"; import { Flag } from "lucide-react"; import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog"; @@ -21,6 +21,8 @@ import { inlineCreateSaveHint, inlineCreateSiteError, } from "@/domain/work-orders/utils/get-inline-create-missing"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; import { WoTableInlineRowCells } from "./wo-table-inline-row-cells"; @@ -71,14 +73,18 @@ export function WoTableInlineRow({ onDuplicateFound, }: WoTableInlineRowProps) { const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY; - const missing = getInlineCreateMissing(draft); + const missing = getInlineCreateMissing(draft, { + requireAccount: isOrgWideScope(useContext(AuthContext)?.user), + }); const canSave = !saving && missing.length === 0; const [showErrors, setShowErrors] = useState(false); const [confirmComplete, setConfirmComplete] = useState(false); const siteCellRef = useRef(null); const woCellRef = useRef(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"); 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..9d6a2207 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -1,14 +1,18 @@ +import { useContext } from "react"; import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react"; import { Button } from "@/components/ui/button"; import { WIZARD_STEPS, canContinueWizardStep1, - canContinueWizardStep2, canCreateWorkOrderFromWizard, isWizardStepValid, isWizardWoNumberValid, + wizardStep2ContinueHint, + type WizardAccountGate, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; +import { AuthContext } from "@/providers/auth-context"; type WizardFooterProps = { step: number; @@ -21,16 +25,19 @@ type WizardFooterProps = { isCreating: boolean; }; -function stepHint(step: number, draft: WorkOrderWizardDraft): string | null { +function stepHint( + step: number, + draft: WorkOrderWizardDraft, + gate: WizardAccountGate, +): string | null { if (step === 1 && !canContinueWizardStep1(draft)) { if (!isWizardWoNumberValid(draft.woNumber)) { return "Enter an alphanumeric WO number, or leave blank for SH generation"; } 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, gate); } return null; } @@ -45,9 +52,10 @@ export function WizardFooter({ onAttemptContinue, isCreating, }: WizardFooterProps) { - const stepValid = isWizardStepValid(step, draft); - const canCreate = canCreateWorkOrderFromWizard(draft); - const hint = stepHint(step, draft); + const accountGate = { requireAccount: isOrgWideScope(useContext(AuthContext)?.user) }; + const stepValid = isWizardStepValid(step, draft, accountGate); + const canCreate = canCreateWorkOrderFromWizard(draft, accountGate); + const hint = stepHint(step, draft, accountGate); return (
void; + 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 orgWide = isOrgWideScope(useContext(AuthContext)?.user); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const { data } = useAccountsList({ page: 1, pageSize: 50, search: debouncedSearch }, orgWide); + const names = (data?.items ?? []).map((account) => account.name.trim()).filter(Boolean); + const selected = customer.trim(); + + if (!orgWide) { + return null; + } + + return ( +
+ Account + + } + searchPlaceholder="Search account name…" + options={accountOptions(names, selected)} + onPick={onCustomerChange} + onSearchChange={setSearch} + /> + + Account is required to create a work order. + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx index 950ca5c8..17425052 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx @@ -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 (
+ onPatch({ customer })} + /> { return useQuery({ queryKey: queryKeys.accounts.list(params as Record), queryFn: () => accountsApi.getList(params), placeholderData: keepPreviousData, + enabled, }); } diff --git a/src/domain/auth/types/auth-user.ts b/src/domain/auth/types/auth-user.ts index 6c5faf2a..6a567b16 100644 --- a/src/domain/auth/types/auth-user.ts +++ b/src/domain/auth/types/auth-user.ts @@ -7,6 +7,9 @@ export interface AuthUser { fullname: string; id: string | number; name?: string; + /** Login payload field. Not trusted for org-wide UI gates; use the JWT claim. */ + orgScope?: string; + accountId?: string | number; } export interface LoginCredentials { diff --git a/src/domain/work-orders/mappers/work-order-board-mapper.ts b/src/domain/work-orders/mappers/work-order-board-mapper.ts index af00a55d..96f09868 100644 --- a/src/domain/work-orders/mappers/work-order-board-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-mapper.ts @@ -141,5 +141,10 @@ export function mapWizardDraftToBoardCreatePayload( payload.locationId = locationId; } + const customer = draft.customer.trim(); + if (customer) { + payload.customer = customer; + } + return payload; } diff --git a/src/domain/work-orders/types/work-order-board.ts b/src/domain/work-orders/types/work-order-board.ts index cbd48acd..4cb9a424 100644 --- a/src/domain/work-orders/types/work-order-board.ts +++ b/src/domain/work-orders/types/work-order-board.ts @@ -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. */ + customer?: string; pocContactId?: number; pocName?: string; pocPhone?: string; diff --git a/src/domain/work-orders/types/work-order-table-row.ts b/src/domain/work-orders/types/work-order-table-row.ts index fa7b7a63..2b0e0493 100644 --- a/src/domain/work-orders/types/work-order-table-row.ts +++ b/src/domain/work-orders/types/work-order-table-row.ts @@ -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, diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index de751c79..20a53a92 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -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, @@ -194,21 +197,56 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean { return true; } -export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean { +export type WizardAccountGate = { + requireAccount?: boolean; +}; + +function requiresAccount(options?: WizardAccountGate): boolean { + return options?.requireAccount !== false; +} + +export function canContinueWizardStep2( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim())); - return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim()); + const hasAccount = !requiresAccount(options) || Boolean(draft.customer.trim()); + return Boolean(hasSite && hasAccount && draft.pocName.trim() && draft.pocPhone.trim()); } -export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean { - return canContinueWizardStep1(draft) && canContinueWizardStep2(draft); +export function wizardStep2ContinueHint( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): string | null { + if (canContinueWizardStep2(draft, options)) { + return null; + } + if (!draft.locationId) { + return "Pick a site to continue"; + } + if (requiresAccount(options) && !draft.customer.trim()) { + return "Select an account to continue"; + } + return "Enter POC name and phone to continue"; } -export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean { +export function canCreateWorkOrderFromWizard( + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { + return canContinueWizardStep1(draft) && canContinueWizardStep2(draft, options); +} + +export function isWizardStepValid( + step: number, + draft: WorkOrderWizardDraft, + options?: WizardAccountGate, +): boolean { if (step === 1) { return canContinueWizardStep1(draft); } if (step === 2) { - return canContinueWizardStep2(draft); + return canContinueWizardStep2(draft, options); } return true; } diff --git a/src/domain/work-orders/utils/get-inline-create-missing.ts b/src/domain/work-orders/utils/get-inline-create-missing.ts index a732e656..6c2ea6e2 100644 --- a/src/domain/work-orders/utils/get-inline-create-missing.ts +++ b/src/domain/work-orders/utils/get-inline-create-missing.ts @@ -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 = { + customer: "Account", site: "Site", woNumber: "WO #", pocName: "POC name", @@ -9,6 +10,7 @@ export const INLINE_CREATE_REQ_LABELS: Record = { }; export type InlineCreateDraftFields = { + customer?: string; site?: string; locationId?: string | number; woNumber?: string; @@ -16,8 +18,18 @@ export type InlineCreateDraftFields = { pocPhone?: string; }; -export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] { +export type InlineCreateAccountGate = { + requireAccount?: boolean; +}; + +export function getInlineCreateMissing( + d: InlineCreateDraftFields, + options?: InlineCreateAccountGate, +): InlineCreateReqField[] { const miss: InlineCreateReqField[] = []; + if (options?.requireAccount !== false && !d.customer?.trim()) { + miss.push("customer"); + } const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim()); if (!hasSite) { miss.push("site"); @@ -43,6 +55,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"; } diff --git a/src/lib/auth/org-scope.ts b/src/lib/auth/org-scope.ts new file mode 100644 index 00000000..681a9e37 --- /dev/null +++ b/src/lib/auth/org-scope.ts @@ -0,0 +1,44 @@ +import type { AuthUser } from "@/domain/auth/types/auth-user"; + +const ORG_SCOPE_ALL = "all"; + +function readJwtClaims(token: string): Record | null { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + try { + const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const parsed: unknown = JSON.parse(globalThis.atob(padded)); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + +function readStringClaim(source: Record | null, keys: string[]): string { + if (!source) { + return ""; + } + for (const key of keys) { + const value = source[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +/** Org-wide only when the JWT claim is explicitly `all`. Fail-closed otherwise. */ +export function isOrgWideScope(user: AuthUser | null | undefined): boolean { + if (!user || typeof user.token !== "string") { + return false; + } + const claims = readJwtClaims(user.token); + const scope = readStringClaim(claims, ["org_scope", "orgScope", "OrgScope"]); + return scope.toLowerCase() === ORG_SCOPE_ALL; +} diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts index ccccd7d3..16b30ee8 100644 --- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts +++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts @@ -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", () => { diff --git a/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx b/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx new file mode 100644 index 00000000..9acafb21 --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-location-account-select.test.tsx @@ -0,0 +1,144 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { AuthContext, type AuthContextValue } from "@/providers/auth-context"; +import { renderWithProviders } from "@/test/test-utils"; + +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, enabled?: boolean) => useAccountsListMock(params, enabled), +})); + +const FOREIGN_ACCOUNT = "Contoso Other Tenant"; + +const ACCOUNTS = { + items: [ + { + id: 1, + name: "Amazon", + number: "", + industry: "", + website: "", + primaryContact: "", + phoneNumber: "", + owner: "", + }, + { + id: 7, + name: FOREIGN_ACCOUNT, + number: "", + industry: "", + website: "", + primaryContact: "", + phoneNumber: "", + owner: "", + }, + ], +}; + +function encodeJwt(payload: Record): string { + const json = JSON.stringify(payload); + const b64 = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + return `eyJhbGciOiJub25lIn0.${b64}.sig`; +} + +function authUser(token: string, orgScope?: string): AuthUser { + return { + token, + expiration: new Date(Date.now() + 60_000).toISOString(), + email: "user@seahavenind.com", + userRoles: "User", + phoneNumber: "", + fullname: "Test User", + id: 1, + orgScope, + }; +} + +function authValue(user: AuthUser): AuthContextValue { + return { + user, + isAuthenticated: true, + isLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + changePassword: vi.fn(), + isLoggingIn: false, + isLoggingOut: false, + isChangingPassword: false, + loginError: null, + }; +} + +function renderSelect(user: AuthUser) { + return renderWithProviders( + + + , + { withAuth: false }, + ); +} + +describe("WizardStepLocationAccountSelect", () => { + beforeEach(() => { + useAccountsListMock.mockReturnValue({ data: ACCOUNTS }); + }); + + it("sets draft.customer to the exact account name on pick for org-wide scope", () => { + const onCustomerChange = vi.fn(); + renderWithProviders( + + + , + { withAuth: false }, + ); + + fireEvent.click(screen.getByRole("button", { name: /select account/i })); + fireEvent.click(screen.getByRole("button", { name: "Amazon" })); + + expect(onCustomerChange).toHaveBeenCalledWith("Amazon"); + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, true); + }); + + it("passes typed search to the accounts list query for org-wide scope", () => { + renderSelect(authUser(encodeJwt({ org_scope: "all" }))); + + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, true); + + 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" }, + true, + ); + }); + + it("does not fetch or expose another account name for a scoped caller", () => { + renderSelect(authUser(encodeJwt({ org_scope: "account", account_id: 7 }))); + + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, false); + expect(screen.queryByRole("button", { name: /select account/i })).not.toBeInTheDocument(); + expect(screen.queryByText(FOREIGN_ACCOUNT)).not.toBeInTheDocument(); + expect(screen.queryByText("Amazon")).not.toBeInTheDocument(); + }); + + it("keeps the list disabled when a scoped token has no org_scope and storage says all", () => { + renderSelect(authUser(encodeJwt({ account_id: 7 }), "all")); + + expect(useAccountsListMock).toHaveBeenCalledWith({ page: 1, pageSize: 50, search: "" }, false); + expect(screen.queryByRole("button", { name: /select account/i })).not.toBeInTheDocument(); + expect(screen.queryByText(FOREIGN_ACCOUNT)).not.toBeInTheDocument(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 9597efee..7e2ccec8 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index d62baec0..5b1e50c8 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -22,9 +22,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e8f8ce8e..2dbdc94e 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -16,9 +16,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx index 9458b1cd..246da779 100644 --- a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx @@ -12,9 +12,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 388ad617..fabf80af 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -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", }); }); }); 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 3c128e93..7079dcb0 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", () => { @@ -123,7 +124,7 @@ describe("wizard step validation", () => { expect(isWizardStepValid(2, draft)).toBe(false); }); - it("allows step 2 when site and POC are set", () => { + it("allows step 2 without customer when account is not required", () => { const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const, @@ -133,8 +134,55 @@ describe("wizard step validation", () => { pocName: "Jordan", pocPhone: "555-0100", }; + expect(canContinueWizardStep2(draft, { requireAccount: false })).toBe(true); + expect(isWizardStepValid(2, draft, { requireAccount: false })).toBe(true); + expect(wizardStep2ContinueHint(draft, { requireAccount: false })).toBeNull(); + }); + + it("blocks step 2 without customer", () => { + const draft = { + ...EMPTY_WIZARD_DRAFT, + type: "PM" as const, + woNumber: "12345", + locationId: "99", + siteCode: "BK5", + pocName: "Jordan", + pocPhone: "555-0100", + }; + 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", () => { + 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); + expect(wizardStep2ContinueHint(draft)).toBeNull(); }); it("blocks create without siteCode", () => { @@ -148,13 +196,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", }; diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index b9b1511d..ee3d5c27 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -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, diff --git a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts index 3ddd8dca..250b4b1d 100644 --- a/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts +++ b/src/test/domain/work-orders/utils/get-inline-create-missing.test.ts @@ -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,29 @@ describe("getInlineCreateMissing", () => { ).toEqual(["pocName", "pocPhone"]); }); - it("lists site and woNumber when empty", () => { - expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]); + it("skips account when the caller does not require it", () => { + expect( + getInlineCreateMissing( + { + site: "BK5", + locationId: "12", + woNumber: "25001", + pocName: "Jane", + pocPhone: "555-0100", + }, + { requireAccount: false }, + ), + ).toEqual([]); + }); + + it("lists account, site, and woNumber when empty", () => { + expect(getInlineCreateMissing({})).toEqual([ + "customer", + "site", + "woNumber", + "pocName", + "pocPhone", + ]); }); }); @@ -58,6 +82,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"); }); diff --git a/src/test/lib/auth/org-scope.test.ts b/src/test/lib/auth/org-scope.test.ts new file mode 100644 index 00000000..51f074c7 --- /dev/null +++ b/src/test/lib/auth/org-scope.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { AuthUser } from "@/domain/auth/types/auth-user"; +import { isOrgWideScope } from "@/lib/auth/org-scope"; + +function encodeJwt(payload: Record): string { + const json = JSON.stringify(payload); + const b64 = btoa(json).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + return `eyJhbGciOiJub25lIn0.${b64}.sig`; +} + +function user(token: string, orgScope?: string): AuthUser { + return { + token, + expiration: new Date(Date.now() + 60_000).toISOString(), + email: "user@seahavenind.com", + userRoles: "User", + phoneNumber: "", + fullname: "Test User", + id: 1, + orgScope, + }; +} + +describe("isOrgWideScope", () => { + it("is false without a session", () => { + expect(isOrgWideScope(null)).toBe(false); + expect(isOrgWideScope(undefined)).toBe(false); + }); + + it("is true only when the JWT org_scope is all", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "all" })))).toBe(true); + expect(isOrgWideScope(user(encodeJwt({ org_scope: "ALL" })))).toBe(true); + }); + + it("is false for a claim-scoped account", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "account", account_id: 7 })))).toBe(false); + }); + + it("ignores a stored orgScope when the token is not a JWT", () => { + expect(isOrgWideScope(user("session-token", "all"))).toBe(false); + }); + + it("ignores a stale stored orgScope when the JWT has no org_scope", () => { + expect(isOrgWideScope(user(encodeJwt({ account_id: 7 }), "all"))).toBe(false); + }); + + it("ignores a stale stored orgScope when the JWT is scoped", () => { + expect(isOrgWideScope(user(encodeJwt({ org_scope: "account" }), "all"))).toBe(false); + }); + + it("is false when the token has no org_scope claim", () => { + expect(isOrgWideScope(user("wo-visual-token"))).toBe(false); + }); +});