mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 09:13:11 +00:00
Merge pull request #136 from Sea-Haven-Industries/fix/sh-221-board-create-customer
fix: send customer on board work-order create
This commit is contained in:
commit
48768cd8c3
36 changed files with 833 additions and 144 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -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: [] });
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type SitePatch = {
|
|||
site: string;
|
||||
location: string;
|
||||
locationId: string | number;
|
||||
customer?: string;
|
||||
pocName: string;
|
||||
pocPhone: string;
|
||||
pocNotes: string;
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HTMLTableCellElement>(null);
|
||||
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||
|
||||
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
|
||||
const siteCellMissing = missing.some(
|
||||
(f) => f === "customer" || f === "site" || f === "pocName" || f === "pocPhone",
|
||||
);
|
||||
const siteShowError = showErrors && siteCellMissing;
|
||||
const woShowError = showErrors && missing.includes("woNumber");
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [str
|
|||
["Schedule on", scheduleOnLabel(draft)],
|
||||
["Due date", fmtDate(draft.dueDate) || "—"],
|
||||
["Assigned to", draft.assigneeName || "—"],
|
||||
["Account", draft.customer.trim() || "—"],
|
||||
["Site", draft.siteCode || draft.locationName || "—"],
|
||||
["POC", pocLabel(draft)],
|
||||
["Additional POCs", formatAdditionalPocSummary(draft)],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { Text } from "@/components/ui/text";
|
|||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||
import { WizardStepLocationAccountSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-account-select";
|
||||
import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields";
|
||||
import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select";
|
||||
import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details";
|
||||
|
|
@ -44,6 +45,11 @@ export function WizardStepLocationService({
|
|||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<WizardStepLocationAccountSelect
|
||||
customer={draft.customer}
|
||||
showError={showErrors}
|
||||
onCustomerChange={(customer) => onPatch({ customer })}
|
||||
/>
|
||||
<WizardStepLocationSiteSelect
|
||||
locationId={draft.locationId}
|
||||
locations={locations}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ 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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -141,5 +141,10 @@ export function mapWizardDraftToBoardCreatePayload(
|
|||
payload.locationId = locationId;
|
||||
}
|
||||
|
||||
const customer = draft.customer.trim();
|
||||
if (customer) {
|
||||
payload.customer = customer;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
/** Required fields for board inline create (Schedule On is optional — SH-109). */
|
||||
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
|
||||
export type InlineCreateReqField = "customer" | "site" | "woNumber" | "pocName" | "pocPhone";
|
||||
|
||||
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
|
||||
customer: "Account",
|
||||
site: "Site",
|
||||
woNumber: "WO #",
|
||||
pocName: "POC name",
|
||||
|
|
@ -9,6 +10,7 @@ export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
|
|||
};
|
||||
|
||||
export type InlineCreateDraftFields = {
|
||||
customer?: string;
|
||||
site?: string;
|
||||
locationId?: string | number;
|
||||
woNumber?: string;
|
||||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
44
src/lib/auth/org-scope.ts
Normal file
44
src/lib/auth/org-scope.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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, 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<typeof import("@/providers/auth-context")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||
useWorkOrderBoardDetail: () => ({
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/providers/auth-context")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||
useWorkOrdersListFilters: () => ({
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/providers/auth-context")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||
useWorkOrdersListFilters: () => ({
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/providers/auth-context")>();
|
||||
return {
|
||||
...actual,
|
||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||
useWorkOrdersListFilters: () => ({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
54
src/test/lib/auth/org-scope.test.ts
Normal file
54
src/test/lib/auth/org-scope.test.ts
Normal file
|
|
@ -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, 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue