Merge pull request #138 from Sea-Haven-Industries/fix/wo-create-site-owned-account
Some checks are pending
CI / ci (push) Waiting to run
CI / governance (push) Waiting to run
CI / visual-regression (push) Waiting to run
Deploy / deploy (push) Waiting to run

fix(work-orders): stop sending customer on create
This commit is contained in:
Arthur Bassi 2026-08-26 11:58:37 -03:00 • committed by GitHub
commit c29ec6b5d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 128 additions and 621 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { useContext, type MouseEvent } from "react";
import 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,12 +9,9 @@ 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 };
@ -23,7 +20,6 @@ type SiteDialogProps = {
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
customer?: string;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
@ -39,7 +35,6 @@ export function SiteDialog({
onOpenChange,
value,
locationId,
customer = "",
pocName = "",
pocPhone = "",
pocNotes = "",
@ -49,20 +44,17 @@ 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,
});
@ -89,13 +81,6 @@ export function SiteDialog({
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{Boolean(createMode && requireAccount) && (
<WizardStepLocationAccountSelect
customer={s.accountName}
showError={s.showErrors && s.accountMissing}
onCustomerChange={s.setAccountName}
/>
)}
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect

View file

@ -1,38 +0,0 @@
import { useEffect, useState } from "react";
type UseSiteDialogAccountFieldArgs = {
open: boolean;
customer: string;
createMode: boolean;
requireAccount?: boolean;
dirty: boolean;
onDirty: () => 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);
},
};
}

View file

@ -1,7 +1,6 @@
import { useEffect } from "react";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
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";
@ -15,14 +14,12 @@ 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;
};
@ -31,14 +28,12 @@ export function useSiteDialogState({
onOpenChange,
value,
locationId,
customer = "",
pocName,
pocPhone,
pocNotes,
additionalContacts = [],
sites,
createMode,
requireAccount = true,
onSave,
}: UseSiteDialogStateArgs) {
const fields = useSiteDialogFormFields({
@ -52,16 +47,7 @@ export function useSiteDialogState({
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,
@ -70,7 +56,7 @@ export function useSiteDialogState({
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
const pocMissing = !pn.trim() || !pp.trim();
const canConfirm = !siteMissing && (!createMode || (!pocMissing && !accountMissing));
const canConfirm = !siteMissing && (!createMode || !pocMissing);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
@ -91,7 +77,6 @@ export function useSiteDialogState({
code: fields.code,
locId: fields.locId,
selected: fields.selected,
...account.customerPatch,
pocName: fields.pn,
pocPhone: fields.pp,
pocNotes: fields.notes,
@ -106,8 +91,6 @@ export function useSiteDialogState({
return {
locId: fields.locId,
code: fields.code,
accountName: account.accountName,
accountMissing: account.accountMissing,
selected: fields.selected,
locationDetail,
locationDetailLoading,
@ -120,7 +103,6 @@ export function useSiteDialogState({
showErrors: fields.showErrors,
siteMissing: fields.siteMissing,
handlePick: fields.handlePick,
setAccountName: account.setAccountName,
attemptSave,
setPn: fields.setPnDirty,
setPp: fields.setPpDirty,

View file

@ -50,7 +50,6 @@ export function WoTableInlineIdentityCells({
value={draft.site}
location={draft.location}
locationId={draft.locationId}
customer={draft.customer}
city={sitePlace?.city}
state={sitePlace?.state}
pocName={draft.pocName}
@ -64,7 +63,6 @@ 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

@ -1,4 +1,4 @@
import { useContext, useRef, useState, type RefObject } from "react";
import { useRef, useState, type RefObject } from "react";
import { Flag } from "lucide-react";
import { WorkOrderCompleteDialog } from "@/app/(protected)/workorders/_components/list/work-order-complete-dialog";
@ -21,9 +21,6 @@ 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";
import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
@ -73,18 +70,14 @@ export function WoTableInlineRow({
onDuplicateFound,
}: WoTableInlineRowProps) {
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY || draftDayKey === UNASSIGNED_DAY_KEY;
const missing = getInlineCreateMissing(draft, {
requireAccount: isOrgWideScope(useContext(AuthContext)?.user),
});
const missing = getInlineCreateMissing(draft);
const canSave = !saving && missing.length === 0;
const [showErrors, setShowErrors] = useState(false);
const [confirmComplete, setConfirmComplete] = useState(false);
const siteCellRef = useRef<HTMLTableCellElement>(null);
const woCellRef = useRef<HTMLTableCellElement>(null);
const siteCellMissing = missing.some(
(f) => f === "customer" || f === "site" || f === "pocName" || f === "pocPhone",
);
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
const siteShowError = showErrors && siteCellMissing;
const woShowError = showErrors && missing.includes("woNumber");

View file

@ -1,18 +1,13 @@
import { useContext } from "react";
import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
WIZARD_STEPS,
canContinueWizardStep1,
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
wizardStep1ContinueHint,
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;
@ -25,19 +20,12 @@ type WizardFooterProps = {
isCreating: boolean;
};
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";
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
if (step === 1) {
return wizardStep1ContinueHint(draft);
}
if (step === 2) {
return wizardStep2ContinueHint(draft, gate);
return wizardStep2ContinueHint(draft);
}
return null;
}
@ -52,10 +40,9 @@ export function WizardFooter({
onAttemptContinue,
isCreating,
}: WizardFooterProps) {
const accountGate = { requireAccount: isOrgWideScope(useContext(AuthContext)?.user) };
const stepValid = isWizardStepValid(step, draft, accountGate);
const canCreate = canCreateWorkOrderFromWizard(draft, accountGate);
const hint = stepHint(step, draft, accountGate);
const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft);
const hint = stepHint(step, draft);
return (
<div

View file

@ -46,7 +46,6 @@ 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

@ -1,63 +0,0 @@
import { useContext, 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";
import { isOrgWideScope } from "@/lib/auth/org-scope";
import { AuthContext } from "@/providers/auth-context";
type WizardStepLocationAccountSelectProps = {
customer: string;
onCustomerChange: (customer: string) => 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 (
<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={accountOptions(names, selected)}
onPick={onCustomerChange}
onSearchChange={setSearch}
/>
<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

@ -1,8 +1,6 @@
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";
@ -45,11 +43,6 @@ 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}
@ -79,16 +72,6 @@ export function WizardStepLocationService({
onUpdate={onUpdate}
onPatch={onPatch}
/>
{!draft.locationId && !draft.pm && (
<Text
as="p"
variant="description"
sx={{ fontSize: 12, fontStyle: "italic", color: "var(--muted-foreground)" }}
>
Both are optional — leave blank to fill in later from the dispatch board.
</Text>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
import {
WO_TYPE_META,
WO_TYPES,
WIZARD_WO_NUMBER_MAX_LENGTH,
isWizardWoNumberValid,
type WOType,
} from "@/domain/work-orders/types/work-order-wizard";
@ -231,28 +232,6 @@ function clearSchedulePatch(): Partial<WorkOrderWizardDraft> {
};
}
function WizardAddOnOverrideField({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-label="Mark as Add-On"
/>
<Text as="p" variant="description" sx={{ fontSize: 13, margin: 0 }}>
Mark as Add-On
</Text>
</label>
);
}
export function WizardStepTypeSchedule({
draft,
users,
@ -294,19 +273,25 @@ export function WizardStepTypeSchedule({
type="text"
value={draft.woNumber}
onChange={(e) =>
onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20))
onUpdate(
"woNumber",
e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, WIZARD_WO_NUMBER_MAX_LENGTH),
)
}
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
placeholder="Leave blank for SH provisional number"
maxLength={20}
maxLength={WIZARD_WO_NUMBER_MAX_LENGTH}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
{Boolean(woNumberError) && (
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
WO number must be alphanumeric.
</Text>
)}
<Text
as="p"
variant="error"
when={Boolean(woNumberError)}
sx={{ fontSize: 11, marginTop: "6px" }}
>
WO number must be alphanumeric.
</Text>
</div>
<div>
@ -338,13 +323,6 @@ export function WizardStepTypeSchedule({
</div>
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
{Boolean(!hasWizardSchedule(draft)) && (
<WizardAddOnOverrideField
checked={Boolean(draft.isAddOnOverride)}
onChange={(value) => onUpdate("isAddOnOverride", value)}
/>
)}
</div>
);
}

View file

@ -5,12 +5,10 @@ import { queryKeys } from "@/infra/query-key/query-key";
export function useAccountsList(
params: AccountsListParams,
enabled = true,
): UseQueryResult<AccountsListResult, Error> {
return useQuery({
queryKey: queryKeys.accounts.list(params as Record<string, unknown>),
queryFn: () => accountsApi.getList(params),
placeholderData: keepPreviousData,
enabled,
});
}

View file

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

View file

@ -107,8 +107,6 @@ 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;

View file

@ -79,8 +79,6 @@ 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;
@ -113,7 +111,6 @@ export interface WorkOrderTableDraft {
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
customer: "",
woNumber: "",
type: "",
severity: null,

View file

@ -28,8 +28,6 @@ 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. */
@ -76,7 +74,6 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
locationId: "",
locationName: "",
siteCode: "",
customer: "",
woNumber: "",
type: "",
severity: null,
@ -106,13 +103,15 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
isAddOnOverride: false,
};
/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */
export const WIZARD_WO_NUMBER_MAX_LENGTH = 20;
/** Blank is valid (backend generates an SH provisional number). Otherwise alphanumeric, max 20. */
export function isWizardWoNumberValid(woNumber: string): boolean {
const trimmed = woNumber.trim();
if (!trimmed) {
return true;
}
return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed);
return trimmed.length <= WIZARD_WO_NUMBER_MAX_LENGTH && /^[a-z0-9]+$/i.test(trimmed);
}
/** Type is optional at creation; empty draft type defaults to PM. */
@ -197,56 +196,41 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return true;
}
export type WizardAccountGate = {
requireAccount?: boolean;
};
function requiresAccount(options?: WizardAccountGate): boolean {
return options?.requireAccount !== false;
export function wizardStep1ContinueHint(draft: WorkOrderWizardDraft): string | null {
if (canContinueWizardStep1(draft)) {
return null;
}
if (!isWizardWoNumberValid(draft.woNumber)) {
return "WO number must be alphanumeric";
}
return "Select severity to continue";
}
export function canContinueWizardStep2(
draft: WorkOrderWizardDraft,
options?: WizardAccountGate,
): boolean {
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
const hasAccount = !requiresAccount(options) || Boolean(draft.customer.trim());
return Boolean(hasSite && hasAccount && draft.pocName.trim() && draft.pocPhone.trim());
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
}
export function wizardStep2ContinueHint(
draft: WorkOrderWizardDraft,
options?: WizardAccountGate,
): string | null {
if (canContinueWizardStep2(draft, options)) {
export function wizardStep2ContinueHint(draft: WorkOrderWizardDraft): string | null {
if (canContinueWizardStep2(draft)) {
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 canCreateWorkOrderFromWizard(
draft: WorkOrderWizardDraft,
options?: WizardAccountGate,
): boolean {
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft, options);
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
}
export function isWizardStepValid(
step: number,
draft: WorkOrderWizardDraft,
options?: WizardAccountGate,
): boolean {
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
if (step === 1) {
return canContinueWizardStep1(draft);
}
if (step === 2) {
return canContinueWizardStep2(draft, options);
return canContinueWizardStep2(draft);
}
return true;
}

View file

@ -1,8 +1,7 @@
/** Required fields for board inline create (Schedule On is optional — SH-109). */
export type InlineCreateReqField = "customer" | "site" | "woNumber" | "pocName" | "pocPhone";
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
customer: "Account",
site: "Site",
woNumber: "WO #",
pocName: "POC name",
@ -10,7 +9,6 @@ export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
};
export type InlineCreateDraftFields = {
customer?: string;
site?: string;
locationId?: string | number;
woNumber?: string;
@ -18,18 +16,8 @@ export type InlineCreateDraftFields = {
pocPhone?: string;
};
export type InlineCreateAccountGate = {
requireAccount?: boolean;
};
export function getInlineCreateMissing(
d: InlineCreateDraftFields,
options?: InlineCreateAccountGate,
): InlineCreateReqField[] {
export function getInlineCreateMissing(d: InlineCreateDraftFields): 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");
@ -55,9 +43,6 @@ 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

@ -1,44 +0,0 @@
import type { AuthUser } from "@/domain/auth/types/auth-user";
const ORG_SCOPE_ALL = "all";
function readJwtClaims(token: string): Record<string, unknown> | 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<string, unknown>;
} catch {
return null;
}
}
function readStringClaim(source: Record<string, unknown> | 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;
}

View file

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

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { buildWizardSidebarSummaryRows } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("buildWizardSidebarSummaryRows", () => {
it("omits Account from the live summary", () => {
const labels = buildWizardSidebarSummaryRows(EMPTY_WIZARD_DRAFT).map(([label]) => label);
expect(labels).not.toContain("Account");
expect(labels).toContain("Site");
});
});

View file

@ -1,144 +0,0 @@
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, unknown>): 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(
<AuthContext.Provider value={authValue(user)}>
<WizardStepLocationAccountSelect customer="" onCustomerChange={vi.fn()} />
</AuthContext.Provider>,
{ 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(
<AuthContext.Provider value={authValue(authUser(encodeJwt({ org_scope: "all" })))}>
<WizardStepLocationAccountSelect customer="" onCustomerChange={onCustomerChange} />
</AuthContext.Provider>,
{ 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();
});
});

View file

@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { EMPTY_WIZARD_DRAFT } from "@/domain/work-orders/types/work-order-wizard";
describe("WizardStepLocationService", () => {
it("requires Site, keeps Service optional, and does not render Account", () => {
render(
<WizardStepLocationService
draft={EMPTY_WIZARD_DRAFT}
locations={[]}
locationDetail={undefined}
services={[]}
svcOpen={false}
onSvcOpenChange={vi.fn()}
svcSearch=""
onSvcSearchChange={vi.fn()}
onUpdate={vi.fn()}
onPatch={vi.fn()}
/>,
);
expect(screen.getByText("Site")).toBeInTheDocument();
expect(screen.getByText("Service")).toBeInTheDocument();
expect(screen.queryByText("Account")).not.toBeInTheDocument();
expect(screen.queryByText(/Both are optional/i)).not.toBeInTheDocument();
});
});

View file

@ -230,9 +230,7 @@ describe("WorkOrdersListPage board-core public interface", () => {
expect(
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));

View file

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

View file

@ -7,6 +7,7 @@ import {
canCreateWorkOrderFromWizard,
isWizardStepValid,
isWizardWoNumberValid,
wizardStep1ContinueHint,
wizardStep2ContinueHint,
} from "@/domain/work-orders/types/work-order-wizard";
@ -66,18 +67,18 @@ describe("mapWizardDraftToFormValues", () => {
});
describe("wizard step validation", () => {
it("allows step 1 continue without a woNumber for backend SH generation", () => {
it("allows step 1 with a blank woNumber for backend SH generation", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true);
});
it("allows step 1 without type when woNumber is set (type defaults to PM)", () => {
it("allows step 1 without type when woNumber is set", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
});
it("allows step 1 when woNumber is missing even with type", () => {
it("allows step 1 without woNumber when type is set", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
@ -124,7 +125,7 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows step 2 without customer when account is not required", () => {
it("allows step 2 without an account when site and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
@ -134,49 +135,32 @@ 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");
expect(canContinueWizardStep2(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
expect(wizardStep2ContinueHint(draft)).toBeNull();
});
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", () => {
it("hints POC when step 2 has site 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", () => {
it("allows step 2 when site 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",
};
@ -196,14 +180,13 @@ describe("wizard step validation", () => {
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows create when type, woNumber, site, account, and POC are set", () => {
it("allows create when type, woNumber, site, and POC are set", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "12345678901234567890",
woNumber: "12345678901",
locationId: "99",
siteCode: "BK5",
customer: "Amazon",
pocName: "Jordan",
pocPhone: "555-0100",
};
@ -212,18 +195,10 @@ describe("wizard step validation", () => {
});
it("allows provisional SH-prefixed woNumber", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
woNumber: "SH12345",
};
expect(canContinueWizardStep1(draft)).toBe(true);
});
it("accepts blank woNumber for backend SH generation", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
woNumber: "",
woNumber: "SH12345",
};
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
expect(canContinueWizardStep1(draft)).toBe(true);
@ -241,10 +216,22 @@ describe("wizard step validation", () => {
it("rejects non-alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("!!!")).toBe(false);
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false);
expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, type: "PM", woNumber: "!!!" })).toBe(
false,
);
});
it("accepts alphanumeric woNumber", () => {
expect(isWizardWoNumberValid("ABC123")).toBe(true);
});
it("hints alphanumeric WO number or severity when step 1 is incomplete", () => {
expect(wizardStep1ContinueHint(EMPTY_WIZARD_DRAFT)).toBeNull();
expect(wizardStep1ContinueHint({ ...EMPTY_WIZARD_DRAFT, type: "PM", woNumber: "!!!" })).toBe(
"WO number must be alphanumeric",
);
expect(wizardStep1ContinueHint({ ...EMPTY_WIZARD_DRAFT, type: "Emergency" })).toBe(
"Select severity to continue",
);
});
});

View file

@ -60,26 +60,27 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
});
});
it("includes customer when the draft has an account name", () => {
it("sends locationId and never customer on create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
customer: " Amazon ",
locationId: "12",
});
expect(payload.customer).toBe("Amazon");
expect(payload.locationId).toBe(12);
expect(payload).not.toHaveProperty("customer");
});
it("omits customer when the draft account is blank", () => {
it("allows create mapping without a service", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
siteCode: "BK5",
customer: " ",
locationId: "12",
});
expect(payload.customer).toBeUndefined();
expect(payload.primaryService).toBeUndefined();
});
it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => {

View file

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

View file

@ -1,54 +0,0 @@
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, unknown>): 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);
});
});