mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
Merge remote-tracking branch 'origin/dev' into fix/ab/sh-117-indicator-accessible-names
This commit is contained in:
commit
57db887deb
30 changed files with 487 additions and 203 deletions
10
.github/workflows/ci.yaml
vendored
10
.github/workflows/ci.yaml
vendored
|
|
@ -1,16 +1,18 @@
|
||||||
name: CI
|
name: Frontend checks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
branches: [main, dev]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
build-and-test:
|
||||||
|
name: Build and test
|
||||||
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
# Org reusable workflow (Node 24): format check, lint, build, unit tests.
|
||||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
|
||||||
with:
|
with:
|
||||||
|
|
@ -28,9 +30,10 @@ jobs:
|
||||||
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
# GOVERNANCE_BASE points the changed-file gate at the right diff:
|
||||||
# PR -> the PR target branch (origin/<base_ref>)
|
# PR -> the PR target branch (origin/<base_ref>)
|
||||||
# push-> the previous commit on the branch (github.event.before)
|
# push-> the previous commit on the branch (github.event.before)
|
||||||
|
# manual -> dev, for exact-head recovery runs
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event.before }}
|
GOVERNANCE_BASE: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'origin/dev' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -43,6 +46,7 @@ jobs:
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
|
|
||||||
visual-regression:
|
visual-regression:
|
||||||
|
name: Visual regression
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 115 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 184 KiB |
|
|
@ -82,6 +82,10 @@ function isBoardDetail(pathname: string) {
|
||||||
return /\/workorders\/\d+\/detail$/.test(pathname);
|
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") {
|
async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") {
|
||||||
await page.clock.setFixedTime(new Date(FROZEN_NOW));
|
await page.clock.setFixedTime(new Date(FROZEN_NOW));
|
||||||
await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER);
|
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;
|
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: [] });
|
await fulfillJson(route, { data: [] });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,7 +195,6 @@ function DrawerBody({
|
||||||
return (
|
return (
|
||||||
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
<Stack spacing={3} className="flex-1 overflow-y-auto" sx={{ p: 2.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<SectionTitle>Company</SectionTitle>
|
|
||||||
<CompanySection roster={roster} />
|
<CompanySection roster={roster} />
|
||||||
</Box>
|
</Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,9 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
const trades = splitTrades(row.tradeSpecialties);
|
const trades = splitTrades(row.tradeSpecialties);
|
||||||
const primaryTrade = trades[0] ?? "—";
|
const primaryTrade = trades[0] ?? "—";
|
||||||
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
const extraTrades = trades.length > 1 ? trades.length - 1 : 0;
|
||||||
const locationText = [row.city, row.state].filter(Boolean).join(", ") || "—";
|
const cityStateText = [row.city.trim(), row.state.trim()].filter(Boolean).join(", ");
|
||||||
|
const locationText = cityStateText || row.address.trim() || "—";
|
||||||
|
const phoneText = row.phone.trim() || row.companyPhone.trim();
|
||||||
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
const rowKey = String(row.id ?? `company-${row.companyId ?? ""}`);
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
|
@ -132,14 +134,14 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps)
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
<TableCell sx={{ color: "var(--color-muted-foreground)" }}>
|
||||||
{row.phone ? (
|
{phoneText ? (
|
||||||
<Link
|
<Link
|
||||||
href={`tel:${row.phone}`}
|
href={`tel:${phoneText}`}
|
||||||
underline="hover"
|
underline="hover"
|
||||||
color="inherit"
|
color="inherit"
|
||||||
onClick={stopPropagation}
|
onClick={stopPropagation}
|
||||||
>
|
>
|
||||||
{row.phone}
|
{phoneText}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
|
|
|
||||||
|
|
@ -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,9 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
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 type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||||
|
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 { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
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 { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
|
@ -37,55 +36,28 @@ export function useSiteDialogState({
|
||||||
createMode,
|
createMode,
|
||||||
onSave,
|
onSave,
|
||||||
}: UseSiteDialogStateArgs) {
|
}: UseSiteDialogStateArgs) {
|
||||||
const [code, setCode] = useState(value);
|
const fields = useSiteDialogFormFields({
|
||||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
open,
|
||||||
const [pn, setPn] = useState(pocName);
|
value,
|
||||||
const [pp, setPp] = useState(pocPhone);
|
locationId,
|
||||||
const [notes, setNotes] = useState(pocNotes);
|
pocName,
|
||||||
const [extraContacts, setExtraContacts] = useState(additionalContacts);
|
pocPhone,
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
pocNotes,
|
||||||
const [dirty, setDirty] = useState(false);
|
additionalContacts,
|
||||||
const [contactsDirty, setContactsDirty] = useState(false);
|
sites,
|
||||||
const [baselineHadContacts, setBaselineHadContacts] = useState(
|
createMode,
|
||||||
() => additionalContacts.length > 0,
|
});
|
||||||
);
|
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
|
||||||
const [showErrors, setShowErrors] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: locationDetail,
|
data: locationDetail,
|
||||||
isLoading: locationDetailLoading,
|
isLoading: locationDetailLoading,
|
||||||
isError: locationDetailError,
|
isError: locationDetailError,
|
||||||
} = useLocationDetail(open && locId ? locId : undefined);
|
} = useLocationDetail(open && locId ? locId : undefined);
|
||||||
|
|
||||||
const selected = sites.find((s) => String(s.id) === locId);
|
|
||||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
const siteMissing = !locId;
|
|
||||||
const pocMissing = !pn.trim() || !pp.trim();
|
const pocMissing = !pn.trim() || !pp.trim();
|
||||||
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -93,82 +65,49 @@ export function useSiteDialogState({
|
||||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||||
setPocFilledFor(locId);
|
setPocFilledFor(locId);
|
||||||
}, [open, locId, locationDetail, pocFilledFor]);
|
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor]);
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const attemptSave = () => {
|
const attemptSave = () => {
|
||||||
if (createMode && !canConfirm) {
|
if (createMode && !canConfirm) {
|
||||||
setShowErrors(true);
|
fields.setShowErrors(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(buildPatch());
|
onSave(
|
||||||
|
buildSiteDialogPatch({
|
||||||
|
code: fields.code,
|
||||||
|
locId: fields.locId,
|
||||||
|
selected: fields.selected,
|
||||||
|
pocName: fields.pn,
|
||||||
|
pocPhone: fields.pp,
|
||||||
|
pocNotes: fields.notes,
|
||||||
|
extraContacts: fields.extraContacts,
|
||||||
|
contactsDirty: fields.contactsDirty,
|
||||||
|
baselineHadContacts: fields.baselineHadContacts,
|
||||||
|
}),
|
||||||
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locId,
|
locId: fields.locId,
|
||||||
code,
|
code: fields.code,
|
||||||
selected,
|
selected: fields.selected,
|
||||||
locationDetail,
|
locationDetail,
|
||||||
locationDetailLoading,
|
locationDetailLoading,
|
||||||
locationDetailError,
|
locationDetailError,
|
||||||
addressPreview,
|
addressPreview,
|
||||||
pn,
|
pn: fields.pn,
|
||||||
pp,
|
pp: fields.pp,
|
||||||
notes,
|
notes: fields.notes,
|
||||||
extraContacts,
|
extraContacts: fields.extraContacts,
|
||||||
showErrors,
|
showErrors: fields.showErrors,
|
||||||
siteMissing,
|
siteMissing: fields.siteMissing,
|
||||||
handlePick,
|
handlePick: fields.handlePick,
|
||||||
attemptSave,
|
attemptSave,
|
||||||
setPn: (next: string) => markPocDirty(() => setPn(next)),
|
setPn: fields.setPnDirty,
|
||||||
setPp: (next: string) => markPocDirty(() => setPp(next)),
|
setPp: fields.setPpDirty,
|
||||||
setNotes: (next: string) => markPocDirty(() => setNotes(next)),
|
setNotes: fields.setNotesDirty,
|
||||||
setExtraContacts: markContactsDirty,
|
setExtraContacts: fields.setExtraContactsDirty,
|
||||||
close: () => onOpenChange(false),
|
close: () => onOpenChange(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import {
|
||||||
inlineCreateSaveHint,
|
inlineCreateSaveHint,
|
||||||
inlineCreateSiteError,
|
inlineCreateSiteError,
|
||||||
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||||
|
|
||||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||||
import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ type WizardFieldSelectProps = {
|
||||||
search?: boolean;
|
search?: boolean;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
onSearchChange?: (query: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] {
|
function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] {
|
||||||
|
|
@ -41,15 +42,22 @@ export function WizardFieldSelect({
|
||||||
search = true,
|
search = true,
|
||||||
searchPlaceholder = "Search…",
|
searchPlaceholder = "Search…",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
onSearchChange,
|
||||||
}: WizardFieldSelectProps) {
|
}: WizardFieldSelectProps) {
|
||||||
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const selected = options.find((o) => o.key === value);
|
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 = () => {
|
const handleClose = () => {
|
||||||
close();
|
close();
|
||||||
setQ("");
|
setQ("");
|
||||||
|
onSearchChange?.("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -117,7 +125,7 @@ export function WizardFieldSelect({
|
||||||
search={search}
|
search={search}
|
||||||
searchPlaceholder={searchPlaceholder}
|
searchPlaceholder={searchPlaceholder}
|
||||||
query={q}
|
query={q}
|
||||||
onQueryChange={setQ}
|
onQueryChange={handleQueryChange}
|
||||||
options={filtered}
|
options={filtered}
|
||||||
value={value}
|
value={value}
|
||||||
onPick={(key) => {
|
onPick={(key) => {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
WIZARD_STEPS,
|
WIZARD_STEPS,
|
||||||
canContinueWizardStep1,
|
|
||||||
canContinueWizardStep2,
|
|
||||||
canCreateWorkOrderFromWizard,
|
canCreateWorkOrderFromWizard,
|
||||||
isWizardStepValid,
|
isWizardStepValid,
|
||||||
isWizardWoNumberValid,
|
wizardStep1ContinueHint,
|
||||||
|
wizardStep2ContinueHint,
|
||||||
type WorkOrderWizardDraft,
|
type WorkOrderWizardDraft,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
|
@ -22,15 +21,11 @@ type WizardFooterProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
|
function stepHint(step: number, draft: WorkOrderWizardDraft): string | null {
|
||||||
if (step === 1 && !canContinueWizardStep1(draft)) {
|
if (step === 1) {
|
||||||
if (!isWizardWoNumberValid(draft.woNumber)) {
|
return wizardStep1ContinueHint(draft);
|
||||||
return "Enter an alphanumeric WO number, or leave blank for SH generation";
|
|
||||||
}
|
|
||||||
return "Select severity to continue";
|
|
||||||
}
|
}
|
||||||
if (step === 2 && !canContinueWizardStep2(draft)) {
|
if (step === 2) {
|
||||||
if (!draft.locationId) return "Pick a site to continue";
|
return wizardStep2ContinueHint(draft);
|
||||||
return "Enter POC name and phone to continue";
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
@ -73,16 +72,6 @@ export function WizardStepLocationService({
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
onPatch={onPatch}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order
|
||||||
import {
|
import {
|
||||||
WO_TYPE_META,
|
WO_TYPE_META,
|
||||||
WO_TYPES,
|
WO_TYPES,
|
||||||
|
WIZARD_WO_NUMBER_MAX_LENGTH,
|
||||||
isWizardWoNumberValid,
|
isWizardWoNumberValid,
|
||||||
type WOType,
|
type WOType,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} 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({
|
export function WizardStepTypeSchedule({
|
||||||
draft,
|
draft,
|
||||||
users,
|
users,
|
||||||
|
|
@ -294,19 +273,25 @@ export function WizardStepTypeSchedule({
|
||||||
type="text"
|
type="text"
|
||||||
value={draft.woNumber}
|
value={draft.woNumber}
|
||||||
onChange={(e) =>
|
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)}
|
onBlur={() => onDuplicateWoNumber?.(draft.woNumber)}
|
||||||
placeholder="Leave blank for SH provisional number"
|
placeholder="Leave blank for SH provisional number"
|
||||||
maxLength={20}
|
maxLength={WIZARD_WO_NUMBER_MAX_LENGTH}
|
||||||
className={DLG_INPUT_CLS}
|
className={DLG_INPUT_CLS}
|
||||||
style={DLG_INPUT_STYLE}
|
style={DLG_INPUT_STYLE}
|
||||||
/>
|
/>
|
||||||
{Boolean(woNumberError) && (
|
<Text
|
||||||
<Text as="p" variant="error" sx={{ fontSize: 11, marginTop: "6px" }}>
|
as="p"
|
||||||
WO number must be alphanumeric.
|
variant="error"
|
||||||
</Text>
|
when={Boolean(woNumberError)}
|
||||||
)}
|
sx={{ fontSize: 11, marginTop: "6px" }}
|
||||||
|
>
|
||||||
|
WO number must be alphanumeric.
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -338,13 +323,6 @@ export function WizardStepTypeSchedule({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
<WizardTypeScheduleDates draft={draft} onUpdate={onUpdate} onPatch={onPatch} />
|
||||||
|
|
||||||
{Boolean(!hasWizardSchedule(draft)) && (
|
|
||||||
<WizardAddOnOverrideField
|
|
||||||
checked={Boolean(draft.isAddOnOverride)}
|
|
||||||
onChange={(value) => onUpdate("isAddOnOverride", value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ export interface AuthUser {
|
||||||
fullname: string;
|
fullname: string;
|
||||||
id: string | number;
|
id: string | number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Login payload field. Not trusted for org-wide UI gates; use the JWT claim. */
|
||||||
|
orgScope?: string;
|
||||||
|
accountId?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
|
|
|
||||||
1
src/domain/vendors/mappers/vendor-mapper.ts
vendored
1
src/domain/vendors/mappers/vendor-mapper.ts
vendored
|
|
@ -119,6 +119,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem {
|
||||||
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"),
|
||||||
notes: readString(item, "notes", "Notes"),
|
notes: readString(item, "notes", "Notes"),
|
||||||
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
totalJobs: readNumber(item, "totalJobs", "TotalJobs"),
|
||||||
|
address: readString(item, "address", "Address"),
|
||||||
city: readString(item, "city", "City"),
|
city: readString(item, "city", "City"),
|
||||||
state: readString(item, "state", "State"),
|
state: readString(item, "state", "State"),
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
|
|
|
||||||
1
src/domain/vendors/types/vendor.ts
vendored
1
src/domain/vendors/types/vendor.ts
vendored
|
|
@ -13,6 +13,7 @@ export interface VendorListItem {
|
||||||
googleMapsUrl: string;
|
googleMapsUrl: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
totalJobs: number;
|
totalJobs: number;
|
||||||
|
address: string;
|
||||||
city: string;
|
city: string;
|
||||||
state: string;
|
state: string;
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
|
|
|
||||||
|
|
@ -103,13 +103,15 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
|
||||||
isAddOnOverride: false,
|
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 {
|
export function isWizardWoNumberValid(woNumber: string): boolean {
|
||||||
const trimmed = woNumber.trim();
|
const trimmed = woNumber.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return true;
|
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. */
|
/** Type is optional at creation; empty draft type defaults to PM. */
|
||||||
|
|
@ -194,11 +196,31 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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): boolean {
|
export function canContinueWizardStep2(draft: WorkOrderWizardDraft): boolean {
|
||||||
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
|
const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.trim()));
|
||||||
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
|
return Boolean(hasSite && draft.pocName.trim() && draft.pocPhone.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wizardStep2ContinueHint(draft: WorkOrderWizardDraft): string | null {
|
||||||
|
if (canContinueWizardStep2(draft)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!draft.locationId) {
|
||||||
|
return "Pick a site to continue";
|
||||||
|
}
|
||||||
|
return "Enter POC name and phone to continue";
|
||||||
|
}
|
||||||
|
|
||||||
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
|
||||||
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
|
return canContinueWizardStep1(draft) && canContinueWizardStep2(draft);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const vendor: VendorListItem = {
|
||||||
googleMapsUrl: "",
|
googleMapsUrl: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
totalJobs: 0,
|
totalJobs: 0,
|
||||||
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
state: "",
|
state: "",
|
||||||
tradeSpecialties: "",
|
tradeSpecialties: "",
|
||||||
|
|
@ -312,6 +313,36 @@ describe("VendorDetailDrawer design parity", () => {
|
||||||
expect(screen.getAllByText("Gateway Plumbing").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("Gateway Plumbing").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows exactly one Company label above the company value in view mode", () => {
|
||||||
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
|
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByText("Company")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByText("Gateway Plumbing").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Notes section heading when notes exist", () => {
|
||||||
|
const roster = rosterWith([
|
||||||
|
{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 },
|
||||||
|
]);
|
||||||
|
roster.data.notes = "Preferred after-hours vendor";
|
||||||
|
useVendorCompanyRoster.mockReturnValue(roster);
|
||||||
|
|
||||||
|
renderWithProviders(<VendorDetailDrawer open={true} vendor={vendor} onClose={vi.fn()} />, {
|
||||||
|
route: "/vendors",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("Notes")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Preferred after-hours vendor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("labels the Google Maps field below Address even when no URL is stored", () => {
|
it("labels the Google Maps field below Address even when no URL is stored", () => {
|
||||||
useVendorCompanyRoster.mockReturnValue(
|
useVendorCompanyRoster.mockReturnValue(
|
||||||
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const activeVendor: VendorListItem = {
|
||||||
googleMapsUrl: "",
|
googleMapsUrl: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
totalJobs: 0,
|
totalJobs: 0,
|
||||||
|
address: "1 Market St",
|
||||||
city: "St. Louis",
|
city: "St. Louis",
|
||||||
state: "MO",
|
state: "MO",
|
||||||
tradeSpecialties: "Backflow Preventers",
|
tradeSpecialties: "Backflow Preventers",
|
||||||
|
|
@ -133,6 +134,53 @@ describe("VendorsListPage", () => {
|
||||||
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Deactivate vendor" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers trimmed city and state over address and technician phone over company phone", () => {
|
||||||
|
setupDefaults();
|
||||||
|
const vendorWithAllFields: VendorListItem = {
|
||||||
|
...activeVendor,
|
||||||
|
id: 3,
|
||||||
|
address: " 1 Market St ",
|
||||||
|
city: " St. Louis ",
|
||||||
|
state: " MO ",
|
||||||
|
phone: " (314) 555-0198 ",
|
||||||
|
};
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([vendorWithAllFields], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
expect(screen.getByText("St. Louis, MO")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/1 Market St/)).not.toBeInTheDocument();
|
||||||
|
const phoneLink = screen.getByRole("link", { name: "(314) 555-0198" });
|
||||||
|
expect(phoneLink).toHaveAttribute("href", "tel:(314) 555-0198");
|
||||||
|
expect(screen.queryByText("(314) 555-0100")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the trimmed address and company phone when technician fields are absent", () => {
|
||||||
|
setupDefaults();
|
||||||
|
const fallbackVendor: VendorListItem = {
|
||||||
|
...activeVendor,
|
||||||
|
id: 3,
|
||||||
|
address: " 1 Market St ",
|
||||||
|
city: " ",
|
||||||
|
state: "",
|
||||||
|
phone: " ",
|
||||||
|
};
|
||||||
|
useVendorsList.mockImplementation((params: { isActive?: boolean; pageSize?: number }) => {
|
||||||
|
if (params.pageSize === 1) return result([], 1);
|
||||||
|
return params.isActive ? result([fallbackVendor], 1) : result([], 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(<VendorsListPage />, { route: "/vendors", withAuth: false });
|
||||||
|
|
||||||
|
expect(screen.getByText("1 Market St")).toBeInTheDocument();
|
||||||
|
const companyPhoneLink = screen.getByRole("link", { name: "(314) 555-0100" });
|
||||||
|
expect(companyPhoneLink).toHaveAttribute("href", "tel:(314) 555-0100");
|
||||||
|
expect(screen.queryByText(/555-0198/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
it("reports the filtered totalCount in the footer when page rows are fewer than total", () => {
|
||||||
setupDefaults();
|
setupDefaults();
|
||||||
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
const pageVendors = Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ describe("buildInlineDraftCreateInput", () => {
|
||||||
expect(payload.scheduleWeekOnly).toBeUndefined();
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
expect(payload.woNumber).toBe("25001");
|
expect(payload.woNumber).toBe("25001");
|
||||||
expect(payload.pocName).toBe("Jane");
|
expect(payload.pocName).toBe("Jane");
|
||||||
|
expect(payload.locationId).toBe(9);
|
||||||
|
expect(payload).not.toHaveProperty("customer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps week-only schedule when targetWeek is set", () => {
|
it("keeps week-only schedule when targetWeek is set", () => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", () => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => {
|
||||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
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", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderBoardDetail: () => ({
|
useWorkOrderBoardDetail: () => ({
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,13 @@ vi.mock("react-router", async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", () => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => {
|
||||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
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", () => ({
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
useWorkOrdersListFilters: () => ({
|
useWorkOrdersListFilters: () => ({
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,13 @@ vi.mock("react-router", async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", () => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => {
|
||||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
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", () => ({
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
useWorkOrdersListFilters: () => ({
|
useWorkOrdersListFilters: () => ({
|
||||||
|
|
@ -226,9 +230,7 @@ describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
expect(
|
expect(
|
||||||
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
|
screen.getByPlaceholderText(/leave blank for sh provisional number/i),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled();
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /continue/i }));
|
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();
|
expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /^back$/i }));
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,13 @@ vi.mock("react-router", async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/providers/auth-context", () => ({
|
vi.mock("@/providers/auth-context", async (importOriginal) => {
|
||||||
useAuthContext: () => ({ user: { id: "u1" } }),
|
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", () => ({
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
useWorkOrdersListFilters: () => ({
|
useWorkOrdersListFilters: () => ({
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ describe("vendorsApi.getList", () => {
|
||||||
CompanyId: "co-1",
|
CompanyId: "co-1",
|
||||||
CompanyName: "Gateway Plumbing",
|
CompanyName: "Gateway Plumbing",
|
||||||
ContactName: "Adam Whyte",
|
ContactName: "Adam Whyte",
|
||||||
|
Address: "1 Market St",
|
||||||
City: "St. Louis",
|
City: "St. Louis",
|
||||||
State: "MO",
|
State: "MO",
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
|
|
@ -45,6 +46,7 @@ describe("vendorsApi.getList", () => {
|
||||||
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 },
|
||||||
});
|
});
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
|
address: "1 Market St",
|
||||||
city: "St. Louis",
|
city: "St. Louis",
|
||||||
state: "MO",
|
state: "MO",
|
||||||
isActive: false,
|
isActive: false,
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ describe("workOrdersApi.createFromBoard", () => {
|
||||||
type: "PM",
|
type: "PM",
|
||||||
siteCode: "BK5",
|
siteCode: "BK5",
|
||||||
woNumber: "12345",
|
woNumber: "12345",
|
||||||
|
locationId: "9",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
|
expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, {
|
||||||
|
|
@ -122,6 +123,7 @@ describe("workOrdersApi.createFromBoard", () => {
|
||||||
lifecycleStatus: 1,
|
lifecycleStatus: 1,
|
||||||
woNumber: "12345",
|
woNumber: "12345",
|
||||||
isAddOn: false,
|
isAddOn: false,
|
||||||
|
locationId: 9,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import {
|
||||||
canCreateWorkOrderFromWizard,
|
canCreateWorkOrderFromWizard,
|
||||||
isWizardStepValid,
|
isWizardStepValid,
|
||||||
isWizardWoNumberValid,
|
isWizardWoNumberValid,
|
||||||
|
wizardStep1ContinueHint,
|
||||||
|
wizardStep2ContinueHint,
|
||||||
} from "@/domain/work-orders/types/work-order-wizard";
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
describe("mapWizardDraftToFormValues", () => {
|
describe("mapWizardDraftToFormValues", () => {
|
||||||
|
|
@ -65,18 +67,18 @@ describe("mapWizardDraftToFormValues", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("wizard step validation", () => {
|
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(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true);
|
||||||
expect(isWizardStepValid(1, 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" };
|
const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" };
|
||||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
expect(isWizardStepValid(1, 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 };
|
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
|
||||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
expect(isWizardStepValid(1, draft)).toBe(true);
|
expect(isWizardStepValid(1, draft)).toBe(true);
|
||||||
|
|
@ -123,6 +125,35 @@ describe("wizard step validation", () => {
|
||||||
expect(isWizardStepValid(2, draft)).toBe(false);
|
expect(isWizardStepValid(2, draft)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows step 2 without an account when site and POC are set", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM" as const,
|
||||||
|
woNumber: "12345",
|
||||||
|
locationId: "99",
|
||||||
|
siteCode: "BK5",
|
||||||
|
pocName: "Jordan",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
};
|
||||||
|
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 but no phone", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
locationId: "99",
|
||||||
|
siteCode: "BK5",
|
||||||
|
pocName: "Jordan",
|
||||||
|
};
|
||||||
|
expect(wizardStep2ContinueHint(draft)).toBe("Enter POC name and phone to continue");
|
||||||
|
});
|
||||||
|
|
||||||
it("allows step 2 when site and POC are set", () => {
|
it("allows step 2 when site and POC are set", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
@ -135,6 +166,7 @@ describe("wizard step validation", () => {
|
||||||
};
|
};
|
||||||
expect(canContinueWizardStep2(draft)).toBe(true);
|
expect(canContinueWizardStep2(draft)).toBe(true);
|
||||||
expect(isWizardStepValid(2, draft)).toBe(true);
|
expect(isWizardStepValid(2, draft)).toBe(true);
|
||||||
|
expect(wizardStep2ContinueHint(draft)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks create without siteCode", () => {
|
it("blocks create without siteCode", () => {
|
||||||
|
|
@ -152,7 +184,7 @@ describe("wizard step validation", () => {
|
||||||
const draft = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
type: "PM" as const,
|
type: "PM" as const,
|
||||||
woNumber: "12345678901234567890",
|
woNumber: "12345678901",
|
||||||
locationId: "99",
|
locationId: "99",
|
||||||
siteCode: "BK5",
|
siteCode: "BK5",
|
||||||
pocName: "Jordan",
|
pocName: "Jordan",
|
||||||
|
|
@ -163,18 +195,10 @@ describe("wizard step validation", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows provisional SH-prefixed woNumber", () => {
|
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 = {
|
const draft = {
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
type: "PM" as const,
|
type: "PM" as const,
|
||||||
woNumber: "",
|
woNumber: "SH12345",
|
||||||
};
|
};
|
||||||
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
expect(isWizardWoNumberValid(draft.woNumber)).toBe(true);
|
||||||
expect(canContinueWizardStep1(draft)).toBe(true);
|
expect(canContinueWizardStep1(draft)).toBe(true);
|
||||||
|
|
@ -192,10 +216,22 @@ describe("wizard step validation", () => {
|
||||||
|
|
||||||
it("rejects non-alphanumeric woNumber", () => {
|
it("rejects non-alphanumeric woNumber", () => {
|
||||||
expect(isWizardWoNumberValid("!!!")).toBe(false);
|
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", () => {
|
it("accepts alphanumeric woNumber", () => {
|
||||||
expect(isWizardWoNumberValid("ABC123")).toBe(true);
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,29 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends locationId and never customer on create", () => {
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM",
|
||||||
|
siteCode: "BK5",
|
||||||
|
locationId: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.locationId).toBe(12);
|
||||||
|
expect(payload).not.toHaveProperty("customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows create mapping without a service", () => {
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM",
|
||||||
|
siteCode: "BK5",
|
||||||
|
locationId: "12",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.primaryService).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => {
|
it("sets isAddOn from cutoff preview when scheduled (create hint; not frozen)", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue