diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 100fff7d..f645d5f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,16 +1,18 @@ -name: CI +name: Frontend checks on: pull_request: branches: [main, dev] push: branches: [main, dev] + workflow_dispatch: {} permissions: contents: read jobs: - ci: + build-and-test: + name: Build and test # Org reusable workflow (Node 24): format check, lint, build, unit tests. uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main with: @@ -28,9 +30,10 @@ jobs: # GOVERNANCE_BASE points the changed-file gate at the right diff: # PR -> the PR target branch (origin/) # push-> the previous commit on the branch (github.event.before) + # manual -> dev, for exact-head recovery runs runs-on: ubuntu-latest 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: - uses: actions/checkout@v4 with: @@ -43,6 +46,7 @@ jobs: - run: npm run verify visual-regression: + name: Visual regression runs-on: ubuntu-latest container: mcr.microsoft.com/playwright:v1.61.1-noble steps: diff --git a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png index 1993c2c7..3b0e6506 100644 Binary files a/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png and b/e2e/__screenshots__/vendors/vendors.visual.spec.ts/vendor-detail.png differ diff --git a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png index e6298c3f..9edaccd5 100644 Binary files a/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png and b/e2e/__screenshots__/work-orders/work-orders.visual.spec.ts/wo-new.png differ diff --git a/e2e/work-orders/work-orders.visual.spec.ts b/e2e/work-orders/work-orders.visual.spec.ts index 0816018f..547723a6 100644 --- a/e2e/work-orders/work-orders.visual.spec.ts +++ b/e2e/work-orders/work-orders.visual.spec.ts @@ -82,6 +82,10 @@ function isBoardDetail(pathname: string) { return /\/workorders\/\d+\/detail$/.test(pathname); } +function isAccountList(pathname: string) { + return pathname.includes("/Account/GetAccountList"); +} + async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" = "default") { await page.clock.setFixedTime(new Date(FROZEN_NOW)); await page.addInitScript((user) => localStorage.setItem("auth", JSON.stringify(user)), TEST_USER); @@ -161,6 +165,27 @@ async function mockWorkOrderApi(page: Page, mode: "default" | "empty" | "error" return; } + if (isAccountList(pathname)) { + await fulfillJson(route, { + items: [ + { + id: 1, + name: "Amazon", + number: "AMZ-1", + industry: "Retail", + website: "", + primaryContact: "", + phoneNumber: "", + owner: "", + }, + ], + totalCount: 1, + page: 1, + pageSize: 50, + }); + return; + } + await fulfillJson(route, { data: [] }); }); } diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 52b328be..49251a83 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -195,7 +195,6 @@ function DrawerBody({ return ( - Company diff --git a/src/app/(protected)/vendors/_components/vendors-table.tsx b/src/app/(protected)/vendors/_components/vendors-table.tsx index e524630c..a680c78b 100644 --- a/src/app/(protected)/vendors/_components/vendors-table.tsx +++ b/src/app/(protected)/vendors/_components/vendors-table.tsx @@ -61,7 +61,9 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) const trades = splitTrades(row.tradeSpecialties); const primaryTrade = trades[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 handleKeyDown = (event: KeyboardEvent) => { @@ -132,14 +134,14 @@ function VendorTableRow({ row, onOpenDetail, onOpenEdit }: VendorTableRowProps) )} - {row.phone ? ( + {phoneText ? ( - {row.phone} + {phoneText} ) : ( "—" diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts new file mode 100644 index 00000000..1b0a4f30 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields.ts @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; + +type UseSiteDialogFormFieldsArgs = { + open: boolean; + value: string; + locationId: string | number; + pocName: string; + pocPhone: string; + pocNotes: string; + additionalContacts: WorkOrderAdditionalContact[]; + sites: LocationOption[]; + createMode: boolean; +}; + +export function useSiteDialogFormFields({ + open, + value, + locationId, + pocName, + pocPhone, + pocNotes, + additionalContacts, + sites, + createMode, +}: UseSiteDialogFormFieldsArgs) { + const [code, setCode] = useState(value); + const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites)); + const [pn, setPn] = useState(pocName); + const [pp, setPp] = useState(pocPhone); + const [notes, setNotes] = useState(pocNotes); + const [extraContacts, setExtraContacts] = useState(additionalContacts); + const [pocFilledFor, setPocFilledFor] = useState(""); + const [dirty, setDirty] = useState(false); + const [contactsDirty, setContactsDirty] = useState(false); + const [baselineHadContacts, setBaselineHadContacts] = useState( + () => additionalContacts.length > 0, + ); + const [showErrors, setShowErrors] = useState(false); + + useEffect(() => { + if (!open) { + setDirty(false); + setContactsDirty(false); + setShowErrors(false); + } + }, [open]); + + useEffect(() => { + if (!open || dirty) { + return; + } + const resolved = resolveLocationId(locationId, value, sites); + setCode(value); + setLocId(resolved); + setPn(pocName); + setPp(pocPhone); + setNotes(pocNotes); + setExtraContacts(additionalContacts); + setBaselineHadContacts(additionalContacts.length > 0); + setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : ""); + }, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]); + + const handlePick = (id: string) => { + const next = sites.find((s) => String(s.id) === id); + const siteChanged = id !== locId; + setLocId(id); + if (next) { + setCode(resolveSiteCode(next)); + } + if (siteChanged) { + setExtraContacts([]); + setContactsDirty(true); + setDirty(true); + if (createMode) { + setPn(""); + setPp(""); + } + setPocFilledFor(""); + } else if (id !== pocFilledFor) { + setPocFilledFor(""); + } + }; + + const markPocDirty = (apply: () => void) => { + setDirty(true); + apply(); + }; + + return { + code, + locId, + pn, + pp, + notes, + extraContacts, + pocFilledFor, + dirty, + contactsDirty, + baselineHadContacts, + showErrors, + selected: sites.find((s) => String(s.id) === locId), + siteMissing: !locId, + setPocFilledFor, + setPn, + setPp, + setShowErrors, + markDirty: () => setDirty(true), + handlePick, + setPnDirty: (next: string) => markPocDirty(() => setPn(next)), + setPpDirty: (next: string) => markPocDirty(() => setPp(next)), + setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)), + setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => { + setDirty(true); + setContactsDirty(true); + setExtraContacts(contacts); + }, + }; +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts index 32df0645..c9d16c1e 100644 --- a/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts +++ b/src/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state.ts @@ -1,10 +1,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 { 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 { 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"; @@ -37,55 +36,28 @@ export function useSiteDialogState({ createMode, 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 { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields; 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]); - useEffect(() => { if (!open || !locId || !locationDetail || pocFilledFor === locId) { return; @@ -93,82 +65,49 @@ 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, + 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, + 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, attemptSave, - setPn: (next: string) => markPocDirty(() => setPn(next)), - setPp: (next: string) => markPocDirty(() => setPp(next)), - setNotes: (next: string) => markPocDirty(() => setNotes(next)), - setExtraContacts: markContactsDirty, + setPn: fields.setPnDirty, + setPp: fields.setPpDirty, + setNotes: fields.setNotesDirty, + setExtraContacts: fields.setExtraContactsDirty, close: () => onOpenChange(false), }; } diff --git a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx index 0e29c529..79432d66 100644 --- a/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx +++ b/src/app/(protected)/workorders/_components/list/table/wo-table-inline-row.tsx @@ -21,7 +21,6 @@ import { inlineCreateSaveHint, inlineCreateSiteError, } from "@/domain/work-orders/utils/get-inline-create-missing"; - import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols"; import { WoTableInlineRowCells } from "./wo-table-inline-row-cells"; import { WoTableInlineRowActions } from "./wo-table-inline-row-actions"; diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx index ad53384d..ede43f70 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx @@ -17,6 +17,7 @@ type WizardFieldSelectProps = { search?: boolean; searchPlaceholder?: string; disabled?: boolean; + onSearchChange?: (query: string) => void; }; function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] { @@ -41,15 +42,22 @@ export function WizardFieldSelect({ search = true, searchPlaceholder = "Search…", disabled = false, + onSearchChange, }: WizardFieldSelectProps) { const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const [q, setQ] = useState(""); const selected = options.find((o) => o.key === value); - const filtered = filterOptions(options, q); + const filtered = onSearchChange ? options : filterOptions(options, q); + + const handleQueryChange = (next: string) => { + setQ(next); + onSearchChange?.(next); + }; const handleClose = () => { close(); setQ(""); + onSearchChange?.(""); }; return ( @@ -117,7 +125,7 @@ export function WizardFieldSelect({ search={search} searchPlaceholder={searchPlaceholder} query={q} - onQueryChange={setQ} + onQueryChange={handleQueryChange} options={filtered} value={value} onPick={(key) => { diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx index 6f01be6a..12ea5609 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -2,11 +2,10 @@ import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react"; import { Button } from "@/components/ui/button"; import { WIZARD_STEPS, - canContinueWizardStep1, - canContinueWizardStep2, canCreateWorkOrderFromWizard, isWizardStepValid, - isWizardWoNumberValid, + wizardStep1ContinueHint, + wizardStep2ContinueHint, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; @@ -22,15 +21,11 @@ type WizardFooterProps = { }; function stepHint(step: number, draft: WorkOrderWizardDraft): 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 === 1) { + return wizardStep1ContinueHint(draft); } - 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); } return null; } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx index 950ca5c8..2ef9f971 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx @@ -1,4 +1,3 @@ -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"; @@ -73,16 +72,6 @@ export function WizardStepLocationService({ onUpdate={onUpdate} onPatch={onPatch} /> - - {!draft.locationId && !draft.pm && ( - - Both are optional — leave blank to fill in later from the dispatch board. - - )} ); } diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx index 2b8f36b7..47cd6ee3 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule.tsx @@ -4,6 +4,7 @@ import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order import { WO_TYPE_META, WO_TYPES, + WIZARD_WO_NUMBER_MAX_LENGTH, isWizardWoNumberValid, type WOType, } from "@/domain/work-orders/types/work-order-wizard"; @@ -231,28 +232,6 @@ function clearSchedulePatch(): Partial { }; } -function WizardAddOnOverrideField({ - checked, - onChange, -}: { - checked: boolean; - onChange: (value: boolean) => void; -}) { - return ( - - ); -} - export function WizardStepTypeSchedule({ draft, users, @@ -294,19 +273,25 @@ export function WizardStepTypeSchedule({ type="text" value={draft.woNumber} onChange={(e) => - onUpdate("woNumber", e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, 20)) + onUpdate( + "woNumber", + e.target.value.replace(/[^a-zA-Z0-9]/g, "").slice(0, WIZARD_WO_NUMBER_MAX_LENGTH), + ) } onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} placeholder="Leave blank for SH provisional number" - maxLength={20} + maxLength={WIZARD_WO_NUMBER_MAX_LENGTH} className={DLG_INPUT_CLS} style={DLG_INPUT_STYLE} /> - {Boolean(woNumberError) && ( - - WO number must be alphanumeric. - - )} + + WO number must be alphanumeric. +
@@ -338,13 +323,6 @@ export function WizardStepTypeSchedule({
- - {Boolean(!hasWizardSchedule(draft)) && ( - onUpdate("isAddOnOverride", value)} - /> - )} ); } diff --git a/src/domain/auth/types/auth-user.ts b/src/domain/auth/types/auth-user.ts index 6c5faf2a..6a567b16 100644 --- a/src/domain/auth/types/auth-user.ts +++ b/src/domain/auth/types/auth-user.ts @@ -7,6 +7,9 @@ export interface AuthUser { fullname: string; id: string | number; name?: string; + /** Login payload field. Not trusted for org-wide UI gates; use the JWT claim. */ + orgScope?: string; + accountId?: string | number; } export interface LoginCredentials { diff --git a/src/domain/vendors/mappers/vendor-mapper.ts b/src/domain/vendors/mappers/vendor-mapper.ts index 33a31223..321e4542 100644 --- a/src/domain/vendors/mappers/vendor-mapper.ts +++ b/src/domain/vendors/mappers/vendor-mapper.ts @@ -119,6 +119,7 @@ export function mapVendorListItem(raw: unknown): VendorListItem { googleMapsUrl: readString(item, "googleMapsUrl", "GoogleMapsUrl"), notes: readString(item, "notes", "Notes"), totalJobs: readNumber(item, "totalJobs", "TotalJobs"), + address: readString(item, "address", "Address"), city: readString(item, "city", "City"), state: readString(item, "state", "State"), tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"), diff --git a/src/domain/vendors/types/vendor.ts b/src/domain/vendors/types/vendor.ts index d4140afa..74e6519e 100644 --- a/src/domain/vendors/types/vendor.ts +++ b/src/domain/vendors/types/vendor.ts @@ -13,6 +13,7 @@ export interface VendorListItem { googleMapsUrl: string; notes: string; totalJobs: number; + address: string; city: string; state: string; tradeSpecialties: string; diff --git a/src/domain/work-orders/types/work-order-wizard.ts b/src/domain/work-orders/types/work-order-wizard.ts index de751c79..f13b387e 100644 --- a/src/domain/work-orders/types/work-order-wizard.ts +++ b/src/domain/work-orders/types/work-order-wizard.ts @@ -103,13 +103,15 @@ export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = { isAddOnOverride: false, }; -/** Blank numbers receive an SH provisional number from the backend; nonblank values are alphanumeric. */ +export const WIZARD_WO_NUMBER_MAX_LENGTH = 20; + +/** Blank is valid (backend generates an SH provisional number). Otherwise alphanumeric, max 20. */ export function isWizardWoNumberValid(woNumber: string): boolean { const trimmed = woNumber.trim(); if (!trimmed) { return true; } - return trimmed.length <= 20 && /^[a-z0-9]+$/i.test(trimmed); + return trimmed.length <= WIZARD_WO_NUMBER_MAX_LENGTH && /^[a-z0-9]+$/i.test(trimmed); } /** Type is optional at creation; empty draft type defaults to PM. */ @@ -194,11 +196,31 @@ export function canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean { 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 { const hasSite = Boolean(draft.locationId && (draft.siteCode.trim() || draft.locationName.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 { return canContinueWizardStep1(draft) && canContinueWizardStep2(draft); } diff --git a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx index a882b91f..d13a9174 100644 --- a/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-detail-drawer.test.tsx @@ -33,6 +33,7 @@ const vendor: VendorListItem = { googleMapsUrl: "", notes: "", totalJobs: 0, + address: "", city: "", state: "", tradeSpecialties: "", @@ -312,6 +313,36 @@ describe("VendorDetailDrawer design parity", () => { 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(, { + 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(, { + 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", () => { useVendorCompanyRoster.mockReturnValue( rosterWith([{ id: 1, contactName: "Adam", phone: "", isActive: true, totalJobs: 5 }]), diff --git a/src/test/app/(protected)/vendors/vendors-list.test.tsx b/src/test/app/(protected)/vendors/vendors-list.test.tsx index ae5a18d3..cb7164e1 100644 --- a/src/test/app/(protected)/vendors/vendors-list.test.tsx +++ b/src/test/app/(protected)/vendors/vendors-list.test.tsx @@ -40,6 +40,7 @@ const activeVendor: VendorListItem = { googleMapsUrl: "", notes: "", totalJobs: 0, + address: "1 Market St", city: "St. Louis", state: "MO", tradeSpecialties: "Backflow Preventers", @@ -133,6 +134,53 @@ describe("VendorsListPage", () => { 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(, { 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(, { 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", () => { setupDefaults(); const pageVendors = Array.from({ length: 3 }, (_, index) => ({ diff --git a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts index ccccd7d3..b8c48168 100644 --- a/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts +++ b/src/test/app/(protected)/workorders/build-inline-draft-create-input.test.ts @@ -30,6 +30,8 @@ describe("buildInlineDraftCreateInput", () => { expect(payload.scheduleWeekOnly).toBeUndefined(); expect(payload.woNumber).toBe("25001"); expect(payload.pocName).toBe("Jane"); + expect(payload.locationId).toBe(9); + expect(payload).not.toHaveProperty("customer"); }); it("keeps week-only schedule when targetWeek is set", () => { diff --git a/src/test/app/(protected)/workorders/wizard-sidebar-summary-rows.test.ts b/src/test/app/(protected)/workorders/wizard-sidebar-summary-rows.test.ts new file mode 100644 index 00000000..126d4d6a --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-sidebar-summary-rows.test.ts @@ -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"); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-step-location-service.test.tsx b/src/test/app/(protected)/workorders/wizard-step-location-service.test.tsx new file mode 100644 index 00000000..76dbd61e --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-step-location-service.test.tsx @@ -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( + , + ); + + 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(); + }); +}); diff --git a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx index 9597efee..7e2ccec8 100644 --- a/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx +++ b/src/test/app/(protected)/workorders/work-order-slide-over-failed-save.test.tsx @@ -3,9 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ useWorkOrderBoardDetail: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx index d62baec0..5b1e50c8 100644 --- a/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-advanced-search-wiring.test.tsx @@ -22,9 +22,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx index e8f8ce8e..e273324c 100644 --- a/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-board-core-affordances.test.tsx @@ -16,9 +16,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ @@ -226,9 +230,7 @@ describe("WorkOrdersListPage board-core public interface", () => { expect( screen.getByPlaceholderText(/leave blank for sh provisional number/i), ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /continue/i })).toBeEnabled(); fireEvent.click(screen.getByRole("button", { name: /continue/i })); - expect(screen.queryByText(/wo number is required/i)).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: /location & service/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /^back$/i })); diff --git a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx index 9458b1cd..246da779 100644 --- a/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx +++ b/src/test/app/(protected)/workorders/work-orders-dispatcher-layout.test.tsx @@ -12,9 +12,13 @@ vi.mock("react-router", async () => { }; }); -vi.mock("@/providers/auth-context", () => ({ - useAuthContext: () => ({ user: { id: "u1" } }), -})); +vi.mock("@/providers/auth-context", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthContext: () => ({ user: { id: "u1" } }), + }; +}); vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({ useWorkOrdersListFilters: () => ({ diff --git a/src/test/domain/vendors/api/vendors-api.test.ts b/src/test/domain/vendors/api/vendors-api.test.ts index 8ed78f32..b5a65e52 100644 --- a/src/test/domain/vendors/api/vendors-api.test.ts +++ b/src/test/domain/vendors/api/vendors-api.test.ts @@ -28,6 +28,7 @@ describe("vendorsApi.getList", () => { CompanyId: "co-1", CompanyName: "Gateway Plumbing", ContactName: "Adam Whyte", + Address: "1 Market St", City: "St. Louis", State: "MO", IsActive: false, @@ -45,6 +46,7 @@ describe("vendorsApi.getList", () => { searchParams: { search: "gateway", isActive: "false", page: 1, pageSize: 12 }, }); expect(result.items[0]).toMatchObject({ + address: "1 Market St", city: "St. Louis", state: "MO", isActive: false, diff --git a/src/test/domain/work-orders/api/work-orders-api.test.ts b/src/test/domain/work-orders/api/work-orders-api.test.ts index 388ad617..a24381d0 100644 --- a/src/test/domain/work-orders/api/work-orders-api.test.ts +++ b/src/test/domain/work-orders/api/work-orders-api.test.ts @@ -114,6 +114,7 @@ describe("workOrdersApi.createFromBoard", () => { type: "PM", siteCode: "BK5", woNumber: "12345", + locationId: "9", }); expect(apiPost).toHaveBeenCalledWith(API_PATHS.workOrder.boardCreate, { @@ -122,6 +123,7 @@ describe("workOrdersApi.createFromBoard", () => { lifecycleStatus: 1, woNumber: "12345", isAddOn: false, + locationId: 9, }); }); }); diff --git a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts index 3c128e93..b1124749 100644 --- a/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/wizard-draft-mapper.test.ts @@ -7,6 +7,8 @@ import { canCreateWorkOrderFromWizard, isWizardStepValid, isWizardWoNumberValid, + wizardStep1ContinueHint, + wizardStep2ContinueHint, } from "@/domain/work-orders/types/work-order-wizard"; describe("mapWizardDraftToFormValues", () => { @@ -65,18 +67,18 @@ describe("mapWizardDraftToFormValues", () => { }); describe("wizard step validation", () => { - it("allows step 1 continue without a woNumber for backend SH generation", () => { + it("allows step 1 with a blank woNumber for backend SH generation", () => { expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(true); expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(true); }); - it("allows step 1 without type when woNumber is set (type defaults to PM)", () => { + it("allows step 1 without type when woNumber is set", () => { const draft = { ...EMPTY_WIZARD_DRAFT, woNumber: "12345" }; expect(canContinueWizardStep1(draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(true); }); - it("allows step 1 when woNumber is missing even with type", () => { + it("allows step 1 without woNumber when type is set", () => { const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const }; expect(canContinueWizardStep1(draft)).toBe(true); expect(isWizardStepValid(1, draft)).toBe(true); @@ -123,6 +125,35 @@ describe("wizard step validation", () => { 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", () => { const draft = { ...EMPTY_WIZARD_DRAFT, @@ -135,6 +166,7 @@ describe("wizard step validation", () => { }; expect(canContinueWizardStep2(draft)).toBe(true); expect(isWizardStepValid(2, draft)).toBe(true); + expect(wizardStep2ContinueHint(draft)).toBeNull(); }); it("blocks create without siteCode", () => { @@ -152,7 +184,7 @@ describe("wizard step validation", () => { const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const, - woNumber: "12345678901234567890", + woNumber: "12345678901", locationId: "99", siteCode: "BK5", pocName: "Jordan", @@ -163,18 +195,10 @@ describe("wizard step validation", () => { }); it("allows provisional SH-prefixed woNumber", () => { - const draft = { - ...EMPTY_WIZARD_DRAFT, - woNumber: "SH12345", - }; - expect(canContinueWizardStep1(draft)).toBe(true); - }); - - it("accepts blank woNumber for backend SH generation", () => { const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const, - woNumber: "", + woNumber: "SH12345", }; expect(isWizardWoNumberValid(draft.woNumber)).toBe(true); expect(canContinueWizardStep1(draft)).toBe(true); @@ -192,10 +216,22 @@ describe("wizard step validation", () => { it("rejects non-alphanumeric woNumber", () => { expect(isWizardWoNumberValid("!!!")).toBe(false); - expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, woNumber: "!!!" })).toBe(false); + expect(canContinueWizardStep1({ ...EMPTY_WIZARD_DRAFT, type: "PM", woNumber: "!!!" })).toBe( + false, + ); }); it("accepts alphanumeric woNumber", () => { expect(isWizardWoNumberValid("ABC123")).toBe(true); }); + + it("hints alphanumeric WO number or severity when step 1 is incomplete", () => { + expect(wizardStep1ContinueHint(EMPTY_WIZARD_DRAFT)).toBeNull(); + expect(wizardStep1ContinueHint({ ...EMPTY_WIZARD_DRAFT, type: "PM", woNumber: "!!!" })).toBe( + "WO number must be alphanumeric", + ); + expect(wizardStep1ContinueHint({ ...EMPTY_WIZARD_DRAFT, type: "Emergency" })).toBe( + "Select severity to continue", + ); + }); }); diff --git a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts index b9b1511d..0f003070 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-mapper.test.ts @@ -60,6 +60,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)", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT,