diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style.ts b/src/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style.ts new file mode 100644 index 00000000..3bb28759 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style.ts @@ -0,0 +1,71 @@ +export type MiniCalendarCellStyle = { + bg: string; + fg: string; + radius: number | string; + filled: boolean; +}; + +function rangeBounds(rangeStart: string, rangeEnd: string): { lo: string; hi: string } { + if (!rangeEnd) { + return { lo: rangeStart, hi: rangeStart }; + } + return rangeStart <= rangeEnd + ? { lo: rangeStart, hi: rangeEnd } + : { lo: rangeEnd, hi: rangeStart }; +} + +function rangeCellStyle( + iso: string, + rangeStart: string, + rangeEnd: string, +): MiniCalendarCellStyle | null { + const { lo, hi } = rangeBounds(rangeStart, rangeEnd); + const filledStyle = { fg: "var(--color-primary-foreground)", bg: "var(--color-primary)" }; + + if (lo === hi) { + return iso === lo ? { ...filledStyle, radius: 4, filled: true } : null; + } + if (iso === lo) { + return { ...filledStyle, radius: "4px 0 0 4px", filled: true }; + } + if (iso === hi) { + return { ...filledStyle, radius: "0 4px 4px 0", filled: true }; + } + if (iso > lo && iso < hi) { + return { bg: "var(--color-primary-tint)", fg: "var(--color-primary)", radius: 0, filled: true }; + } + return null; +} + +/** Resolves the visual state of a single day cell for both single-value and range selection. */ +export function getMiniCalendarCellStyle(args: { + iso: string; + value: string; + isToday: boolean; + range?: boolean; + rangeStart?: string; + rangeEnd?: string; +}): MiniCalendarCellStyle { + const { iso, value, isToday, range, rangeStart = "", rangeEnd = "" } = args; + const defaultStyle: MiniCalendarCellStyle = { + bg: "transparent", + fg: isToday ? "var(--color-primary)" : "var(--color-text-primary)", + radius: 4, + filled: false, + }; + + if (range && rangeStart) { + return rangeCellStyle(iso, rangeStart, rangeEnd) ?? defaultStyle; + } + + if (iso === value) { + return { + bg: "var(--color-primary)", + fg: "var(--color-primary-foreground)", + radius: 4, + filled: true, + }; + } + + return defaultStyle; +} diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx index 4c4132fd..e9aa9dab 100644 --- a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; +import { getMiniCalendarCellStyle } from "@/app/(protected)/workorders/_components/wizard/mini-calendar-cell-style"; import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"]; @@ -7,10 +8,21 @@ const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"]; type MiniCalendarProps = { value: string; onChange: (iso: string) => void; + /** Range-selection mode — parent owns the click logic. */ + range?: boolean; + rangeStart?: string; + rangeEnd?: string; }; -export function MiniCalendar({ value, onChange }: MiniCalendarProps) { - const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso(); +export function MiniCalendar({ + value, + onChange, + range, + rangeStart = "", + rangeEnd = "", +}: MiniCalendarProps) { + const initRaw = range ? rangeStart || value : value; + const init = /^\d{4}-\d{2}-\d{2}$/.test(initRaw) ? initRaw : todayIso(); const [iy, im] = init.split("-").map(Number); const [viewY, setViewY] = useState(iy); const [viewM, setViewM] = useState(im); @@ -81,8 +93,16 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) { return ; } const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; - const selected = iso === value; const isToday = iso === today; + const { bg, fg, radius, filled } = getMiniCalendarCellStyle({ + iso, + value, + isToday, + range, + rangeStart, + rangeEnd, + }); + return ( - ); - })} - - )} + {allowWeekMode && cellMode === "week" ? ( + ) : allowRange && cellMode === "range" ? ( + { + onChangeRange?.(start, end); + close(); + }} + /> ) : ( void; +}; + +function modeLabel(mode: WizardSchedMode, allowRange: boolean): string { + if (mode === "week") { + return "Week"; + } + if (mode === "range") { + return "Range"; + } + return allowRange ? "Date" : "Specific date"; +} + +export function WizardDateModeTabs({ + modes, + cellMode, + allowRange, + onSelect, +}: WizardDateModeTabsProps) { + if (modes.length === 0) { + return null; + } + + return ( +
+ {modes.map((m) => { + const activeMode = cellMode === m; + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-date-range-panel.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-date-range-panel.tsx new file mode 100644 index 00000000..72f9a73d --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-date-range-panel.tsx @@ -0,0 +1,65 @@ +import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar"; +import { rangeDayCount } from "@/domain/work-orders/utils/wizard-date-utils"; + +type WizardDateRangePanelProps = { + rangeStart: string; + rangeEnd: string; + onRangeClick: (iso: string) => void; + onDone: (start: string, end: string) => void; +}; + +export function WizardDateRangePanel({ + rangeStart, + rangeEnd, + onRangeClick, + onDone, +}: WizardDateRangePanelProps) { + const canConfirm = Boolean(rangeStart && rangeEnd); + + return ( + <> + + {Boolean(rangeStart) && ( +
+ {(() => { + const n = rangeDayCount(rangeStart, rangeEnd || rangeStart); + return `${n} ${n === 1 ? "day" : "days"} selected`; + })()} +
+ )} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx new file mode 100644 index 00000000..8eba4e0e --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-options.tsx @@ -0,0 +1,86 @@ +import { Check } from "lucide-react"; +import { Text } from "@/components/ui/text"; +import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types"; + +type WizardFieldSelectOptionsProps = { + search: boolean; + searchPlaceholder: string; + query: string; + onQueryChange: (q: string) => void; + options: WizardMenuOption[]; + value: string; + onPick: (key: string) => void; +}; + +export function WizardFieldSelectOptions({ + search, + searchPlaceholder, + query, + onQueryChange, + options, + value, + onPick, +}: WizardFieldSelectOptionsProps) { + return ( + <> + {search && ( +
+ onQueryChange(e.target.value)} + placeholder={searchPlaceholder} + className="w-full rounded px-2 py-1 text-[12px] focus:outline-none" + style={{ + border: "1px solid var(--color-border)", + color: "var(--color-text-primary)", + background: "var(--color-bg-surface)", + }} + /> +
+ )} +
+ {options.length === 0 ? ( + + No matches + + ) : ( + options.map((o) => ( + + )) + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts new file mode 100644 index 00000000..17958148 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select-types.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +export interface WizardMenuOption { + key: string; + label: string; + description?: string; + left?: ReactNode; + right?: ReactNode; +} 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 460ecec5..ad53384d 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx @@ -1,15 +1,12 @@ import { useState, type ReactNode } from "react"; -import { Check, ChevronDown } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { Popover } from "@mui/material"; import { MenuAnchorButton } from "@/components/ui/menu-anchor-button"; -import { Text } from "@/components/ui/text"; +import { WizardFieldSelectOptions } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-options"; +import type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types"; import { useMenuAnchor } from "@/hooks/use-menu-anchor"; -export interface WizardMenuOption { - key: string; - label: string; - left?: ReactNode; -} +export type { WizardMenuOption } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select-types"; type WizardFieldSelectProps = { value: string; @@ -22,6 +19,19 @@ type WizardFieldSelectProps = { disabled?: boolean; }; +function filterOptions(options: WizardMenuOption[], query: string): WizardMenuOption[] { + if (!query) { + return options; + } + const needle = query.toLowerCase(); + return options.filter( + (o) => + o.label.toLowerCase().includes(needle) || + o.key.toLowerCase().includes(needle) || + (o.description?.toLowerCase().includes(needle) ?? false), + ); +} + export function WizardFieldSelect({ value, placeholder, @@ -35,13 +45,7 @@ export function WizardFieldSelect({ const { anchorEl, open, setAnchor, close } = useMenuAnchor(); const [q, setQ] = useState(""); const selected = options.find((o) => o.key === value); - const filtered = q - ? options.filter( - (o) => - o.label.toLowerCase().includes(q.toLowerCase()) || - o.key.toLowerCase().includes(q.toLowerCase()), - ) - : options; + const filtered = filterOptions(options, q); const handleClose = () => { close(); @@ -74,9 +78,8 @@ export function WizardFieldSelect({ }} aria-haspopup="listbox" > - {icon != null && {icon}} - {icon == null && selected?.left != null && ( - {selected.left} + {(selected?.left ?? icon) != null && ( + {selected?.left ?? icon} )} {selected ? ( - {search && ( -
- setQ(e.target.value)} - placeholder={searchPlaceholder} - className="w-full rounded px-2 py-1 text-[12px] focus:outline-none" - style={{ - border: "1px solid var(--color-border)", - color: "var(--color-text-primary)", - background: "var(--color-bg-surface)", - }} - /> -
- )} -
- {filtered.length === 0 ? ( - - No matches - - ) : ( - filtered.map((o) => ( - - )) - )} -
+ { + onPick(key); + handleClose(); + }} + /> ); diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts new file mode 100644 index 00000000..e49e267a --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts @@ -0,0 +1,58 @@ +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { + fmtDate, + fmtDateRange, + fmtWeekRange, + formatApptRange, +} from "@/domain/work-orders/utils/wizard-date-utils"; + +function scheduleOnLabel(draft: WorkOrderWizardDraft): string { + if (draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd) { + return fmtDateRange(draft.scheduledOn, draft.scheduleEnd); + } + if (!draft.scheduledOn) { + return "—"; + } + if (draft.scheduleWeekOnly) { + return fmtWeekRange(draft.targetWeek || draft.scheduledOn); + } + return fmtDate(draft.scheduledOn); +} + +function pocLabel(draft: WorkOrderWizardDraft): string { + if (!draft.pocName) { + return "—"; + } + return draft.pocPhone ? `${draft.pocName} · ${draft.pocPhone}` : draft.pocName; +} + +function formatAdditionalPocSummary(draft: WorkOrderWizardDraft): string { + const count = draft.additionalContacts.length; + if (count === 0) { + return "—"; + } + const names = draft.additionalContacts.map((contact) => contact.name.trim()).filter(Boolean); + if (names.length > 0) { + return names.join(", "); + } + return `${count} contact${count === 1 ? "" : "s"}`; +} + +/** Builds the label/value rows for the wizard sidebar's live summary card. */ +export function buildWizardSidebarSummaryRows(draft: WorkOrderWizardDraft): [string, string][] { + return [ + ["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"], + ["Type", draft.type || "—"], + ["Schedule on", scheduleOnLabel(draft)], + ["Due date", fmtDate(draft.dueDate) || "—"], + ["Assigned to", draft.assigneeName || "—"], + ["Site", draft.siteCode || draft.locationName || "—"], + ["POC", pocLabel(draft)], + ["Additional POCs", formatAdditionalPocSummary(draft)], + ["Service", draft.pm || "—"], + ["Technician", draft.vendorName || "—"], + ["Company", draft.vendorName || "—"], + ["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"], + ["Status", draft.status || "Incomplete"], + ]; +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary.tsx new file mode 100644 index 00000000..e330f575 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary.tsx @@ -0,0 +1,82 @@ +import { + WIZARD_STATUS_DOTS, + type WizardWOStatus, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { buildWizardSidebarSummaryRows } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows"; +import { ColorDot } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +export function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) { + const summary = buildWizardSidebarSummaryRows(draft); + const statusLabel = draft.status || "Incomplete"; + const statusValue = statusLabel as WizardWOStatus; + const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; + + return ( +
+
+ This work order +
+ {summary.map(([label, val], i) => ( +
+ + {label} + + {label === "Status" ? ( + + + + {statusLabel} + + + ) : ( + + {val} + + )} +
+ ))} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx index a8416139..599768a5 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx @@ -1,19 +1,10 @@ import { ClipboardList } from "lucide-react"; import { - WIZARD_STATUS_DOTS, WIZARD_STEPS, - type WizardWOStatus, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; -import { - fmtDate, - fmtWeekRange, - formatApptRange, -} from "@/domain/work-orders/utils/wizard-date-utils"; -import { - ColorDot, - StepCircle, -} from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { StepCircle } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { WizardSidebarSummary } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary"; type WizardSidebarProps = { step: number; @@ -120,119 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) { ); } - -function formatAdditionalPocSummary(draft: WorkOrderWizardDraft): string { - const count = draft.additionalContacts.length; - if (count === 0) { - return "—"; - } - const names = draft.additionalContacts.map((contact) => contact.name.trim()).filter(Boolean); - if (names.length > 0) { - return names.join(", "); - } - return `${count} contact${count === 1 ? "" : "s"}`; -} - -function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) { - const summary: [string, string][] = [ - ["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"], - ["Type", draft.type || "—"], - [ - "Schedule on", - draft.scheduledOn - ? draft.scheduleWeekOnly - ? fmtWeekRange(draft.scheduledOn) - : fmtDate(draft.scheduledOn) - : "—", - ], - ["Due date", fmtDate(draft.dueDate) || "—"], - ["Assigned to", draft.assigneeName || "—"], - ["Site", draft.locationName || "—"], - [ - "POC", - draft.pocName - ? draft.pocPhone - ? `${draft.pocName} · ${draft.pocPhone}` - : draft.pocName - : "—", - ], - ["Additional POCs", formatAdditionalPocSummary(draft)], - ["Service", draft.pm || "—"], - ["Technician", draft.vendorName || "—"], - ["Company", draft.vendorName || "—"], - ["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"], - ["Status", draft.status || "Incomplete"], - ]; - - const statusLabel = draft.status || "Incomplete"; - const statusValue = statusLabel as WizardWOStatus; - const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; - - return ( -
-
- This work order -
- {summary.map(([label, val], i) => ( -
- - {label} - - {label === "Status" ? ( - - - - {statusLabel} - - - ) : ( - - {val} - - )} -
- ))} -
- ); -} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-header.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-header.tsx new file mode 100644 index 00000000..29976864 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-header.tsx @@ -0,0 +1,42 @@ +import { Text } from "@/components/ui/text"; +import { WIZARD_STEPS } from "@/domain/work-orders/types/work-order-wizard"; + +export function WizardStepHeader({ step }: { step: number }) { + return ( +
+
+ Step {step} of {WIZARD_STEPS.length} +
+ + {WIZARD_STEPS[step - 1].title} + + + {WIZARD_STEPS[step - 1].subtitle} + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx new file mode 100644 index 00000000..a76b2521 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx @@ -0,0 +1,23 @@ +import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; + +type WizardStepLocationPocFieldsProps = { + draft: WorkOrderWizardDraft; + onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; +}; + +export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) { + return ( + onUpdate("pocName", value)} + onPocPhoneChange={(value) => onUpdate("pocPhone", value)} + onPocNotesChange={(value) => onUpdate("pocNotes", value)} + onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} + helperText="Auto-filled from records — edit if outdated" + /> + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx new file mode 100644 index 00000000..0acc1014 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select.tsx @@ -0,0 +1,99 @@ +import { Droplets, Plus } from "lucide-react"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { WizardServiceNotesFields } from "@/app/(protected)/workorders/_components/wizard/wizard-service-notes-fields"; +import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +type ServiceOption = { id: string | number; value: string }; + +type WizardStepLocationServiceSelectProps = { + draft: WorkOrderWizardDraft; + services: ServiceOption[]; + svcOpen: boolean; + onSvcOpenChange: (open: boolean) => void; + svcSearch: string; + onSvcSearchChange: (q: string) => void; + onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; + onPatch: (patch: Partial) => void; +}; + +export function WizardStepLocationServiceSelect({ + draft, + services, + svcOpen, + onSvcOpenChange, + svcSearch, + onSvcSearchChange, + onUpdate, + onPatch, +}: WizardStepLocationServiceSelectProps) { + const clearVendorIfPrimaryChanged = (nextPm: string): Partial => + nextPm !== draft.pm ? { vendorId: "", vendorName: "", techPhone: "" } : {}; + + const selectSvc = (p: string) => { + onPatch({ + pm: p, + extraServices: [], + ...clearVendorIfPrimaryChanged(p), + }); + }; + + return ( +
+ Service + + } + searchPlaceholder="Search PM type…" + options={services.map((s) => { + const Icon = getPmTypeIcon(s.value); + return { + key: s.value, + label: s.value, + left: ( + + ), + }; + })} + onPick={selectSvc} + /> + {!svcOpen ? ( + + ) : ( +
+ onUpdate("pmNote", v)} + search={svcSearch} + setSearch={onSvcSearchChange} + /> +
+ )} +
+ ); +} 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 93adfb8e..7afb2d25 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,11 +1,11 @@ -import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react"; import { Text } from "@/components/ui/text"; -import { PocContactFields } from "@/app/(protected)/workorders/_components/poc/poc-contact-fields"; import type { LocationOption } from "@/domain/work-orders/types/work-order"; import type { Location } from "@/domain/locations/types/location"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; -import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; -import { Detail, WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; +import { WizardStepLocationPocFields } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields"; +import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select"; +import { WizardStepLocationSiteDetails } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-details"; +import { WizardStepLocationSiteSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select"; type ServiceOption = { id: string | number; value: string }; @@ -13,129 +13,64 @@ type WizardStepLocationServiceProps = { draft: WorkOrderWizardDraft; locations: LocationOption[]; locationDetail: Location | undefined; + locationDetailLoading?: boolean; + locationDetailError?: boolean; services: ServiceOption[]; + svcOpen: boolean; + onSvcOpenChange: (open: boolean) => void; + svcSearch: string; + onSvcSearchChange: (q: string) => void; onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; onPatch: (patch: Partial) => void; }; -function buildMapsUrl(location: Location): string { - const parts = [location.address, location.city, location.state, location.zipCode] - .filter(Boolean) - .join(", "); - return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`; -} - export function WizardStepLocationService({ draft, locations, locationDetail, + locationDetailLoading, + locationDetailError, services, + svcOpen, + onSvcOpenChange, + svcSearch, + onSvcSearchChange, onUpdate, onPatch, }: WizardStepLocationServiceProps) { + const selectedLoc = locations.find((l) => String(l.id) === draft.locationId); + return (
-
- Site - - } - searchPlaceholder="Search site code or city…" - options={locations.map((loc) => ({ - key: String(loc.id), - label: loc.name, - }))} - onPick={(id) => { - const loc = locations.find((l) => String(l.id) === id); - onPatch({ - locationId: id, - locationName: loc?.name ?? "", - additionalContacts: [], - }); - }} - /> - {!draft.locationId && ( - - Site is required to continue. - - )} -
+ - {locationDetail != null && draft.locationId !== "" && ( + {draft.locationId !== "" && ( <> -
- - {Boolean(locationDetail.phone) && ( - - )} - {Boolean(locationDetail.contactEmail) && ( - - )} - {Boolean(locationDetail.address) && ( - - Open in Google Maps - - )} -
- onUpdate("pocName", value)} - onPocPhoneChange={(value) => onUpdate("pocPhone", value)} - onPocNotesChange={(value) => onUpdate("pocNotes", value)} - onAdditionalContactsChange={(contacts) => onUpdate("additionalContacts", contacts)} - helperText="Auto-filled from records — edit if outdated" + + )} -
- Service - - } - searchPlaceholder="Search PM type…" - options={services.map((s) => ({ - key: s.value, - label: s.value, - }))} - onPick={(p) => onUpdate("pm", p)} - /> -
+ {!draft.locationId && !draft.pm && ( + + +
+ ); +} + +export function WizardStepLocationSiteDetails({ + selectedLoc, + locationName, + locationDetail, + locationDetailLoading, + locationDetailError, +}: WizardStepLocationSiteDetailsProps) { + const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; + + return ( +
+ {locationDetail ? ( + <> + + {Boolean(locationDetail.phone) && ( + + )} + {Boolean(locationDetail.contactEmail) && ( + + )} + {Boolean(addressPreview) && ( + + Open in Google Maps + + )} + + ) : ( + <> + + {Boolean(locationDetailLoading) && } + {Boolean(locationDetailError) && ( + + Could not load full site details. City and site code above are still available. + + )} + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx new file mode 100644 index 00000000..583828d9 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx @@ -0,0 +1,61 @@ +import { Info, MapPin } from "lucide-react"; +import { Text } from "@/components/ui/text"; +import type { LocationOption } from "@/domain/work-orders/types/work-order"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; +import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; +import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; +import { WizLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels"; + +type WizardStepLocationSiteSelectProps = { + locationId: string; + locations: LocationOption[]; + onPatch: (patch: Partial) => void; +}; + +export function WizardStepLocationSiteSelect({ + locationId, + locations, + onPatch, +}: WizardStepLocationSiteSelectProps) { + return ( +
+ Site + + } + searchPlaceholder="Search site code or city…" + options={locations.map((loc) => ({ + key: String(loc.id), + label: loc.name, + }))} + onPick={(id) => { + const loc = locations.find((l) => String(l.id) === id); + // Switching site invalidates the previous POC — clear so autofill can + // seed from the new location without clobbering mid-load edits later. + onPatch({ + locationId: id, + locationName: loc?.name ?? "", + siteCode: loc ? resolveSiteCode(loc) : "", + pocName: "", + pocPhone: "", + pocNotes: "", + additionalContacts: [], + }); + }} + /> + {!locationId && ( + + Site is required to continue. + + )} +
+ ); +} 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 7f35c2e7..4fe4d77b 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 @@ -2,6 +2,7 @@ import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; import { Text } from "@/components/ui/text"; import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { WO_TYPE_META, WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; +import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color"; import { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field"; import { WizardDueDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-due-date-field"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; @@ -127,6 +128,8 @@ export function WizardStepTypeSchedule({ onPatch, onDuplicateWoNumber, }: WizardStepTypeScheduleProps) { + const selectedUser = users.find((u) => String(u.id) === draft.assigneeId); + return (
@@ -138,10 +141,11 @@ export function WizardStepTypeSchedule({ WO Number onUpdate("woNumber", e.target.value)} + onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))} onBlur={() => onDuplicateWoNumber?.(draft.woNumber)} - placeholder="e.g. 24818" + placeholder="e.g. 12345678901" maxLength={20} className={DLG_INPUT_CLS} style={DLG_INPUT_STYLE} @@ -154,13 +158,17 @@ export function WizardStepTypeSchedule({ value={draft.assigneeId} placeholder="Select…" icon={ - + selectedUser ? ( + + ) : ( + + ) } searchPlaceholder="Search dispatcher…" options={users.map((u) => ({ key: String(u.id), label: u.name, - left: , + left: , }))} onPick={(id) => { const user = users.find((u) => String(u.id) === id); @@ -177,8 +185,15 @@ export function WizardStepTypeSchedule({ onPatch({ scheduledOn: "", scheduleWeekOnly: false }) + draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd + ? () => + onPatch({ + scheduledOn: "", + scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: undefined, + targetWeek: "", + }) : undefined } > @@ -186,11 +201,38 @@ export function WizardStepTypeSchedule({ onPatch({ scheduledOn: iso, scheduleWeekOnly: false })} - onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })} + scheduleEnd={draft.scheduleEnd} + schedMode={draft.schedMode} + onChange={(iso) => + onPatch({ + scheduledOn: iso, + scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: "date", + targetWeek: "", + }) + } + onChangeWeek={(w) => + onPatch({ + scheduledOn: w, + scheduleWeekOnly: Boolean(w), + scheduleEnd: "", + schedMode: "week", + targetWeek: w, + }) + } + onChangeRange={(s, e) => + onPatch({ + scheduledOn: s, + scheduleEnd: e, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }) + } />
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx index d41c939a..7dbcb199 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time.tsx @@ -1,21 +1,21 @@ -import { Clock, ExternalLink, Mail, Wrench } from "lucide-react"; +import { Clock, ExternalLink, Mail, User, Wrench } from "lucide-react"; import { Text } from "@/components/ui/text"; import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order"; import { - ALL_WIZARD_STATUSES, TIME_SLOTS, - WIZARD_STATUS_DOTS, - type WizardWOStatus, type WorkOrderWizardDraft, } from "@/domain/work-orders/types/work-order-wizard"; import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils"; +import { + formatVendorDistance, + formatVendorTechnicianLabel, +} from "@/domain/work-orders/utils/vendor-dropdown-label"; import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { DLG_INPUT_CLS, DLG_INPUT_STYLE, } from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles"; import { - ColorDot, Detail, WizLabel, WizLabelRow, @@ -33,11 +33,13 @@ function WizardVendorDetail({ vendor, techPhone, vendorNotes, + distance, onUpdate, }: { vendor: VendorDropdownItem; techPhone: string; vendorNotes: string; + distance: string; onUpdate: (key: K, value: WorkOrderWizardDraft[K]) => void; }) { return ( @@ -61,9 +63,20 @@ function WizardVendorDetail({ border: "1px solid var(--color-border)", }} > -
- {vendor.companyName || "—"} +
+
+ {vendor.companyName || "—"} +
+ {Boolean(distance) && ( + + {distance} + + )}
+ {Boolean(vendor.contactName) && } + {Boolean(vendor.tradeSpecialties) && ( + + )} {Boolean(vendor.address) && } {Boolean(vendor.address) && ( String(v.id) === draft.vendorId); - const statusValue = (draft.status || "Incomplete") as WizardWOStatus; - const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete; + const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles); return (
@@ -133,7 +145,9 @@ export function WizardStepVendorTime({ color: "var(--color-text-secondary)", }} > - {selectedVendor.companyName.slice(0, 2).toUpperCase()} + {(selectedVendor.contactName || selectedVendor.companyName) + .slice(0, 2) + .toUpperCase()} ) : ( @@ -142,7 +156,7 @@ export function WizardStepVendorTime({ searchPlaceholder="Search technician…" options={vendors.map((v) => ({ key: String(v.id), - label: v.companyName, + label: formatVendorTechnicianLabel(v), }))} onPick={(id) => { const vendor = vendors.find((v) => String(v.id) === id); @@ -160,6 +174,7 @@ export function WizardStepVendorTime({ vendor={selectedVendor} techPhone={draft.techPhone} vendorNotes={draft.vendorNotes} + distance={selectedDistance} onUpdate={onUpdate} /> )} @@ -213,22 +228,6 @@ export function WizardStepVendorTime({ )}
- -
- Status - } - options={ALL_WIZARD_STATUSES.map((s) => ({ - key: s, - label: s, - left: , - }))} - onPick={(s) => onUpdate("status", s as WizardWOStatus)} - /> -
); } diff --git a/src/app/(protected)/workorders/_components/work-order-form.tsx b/src/app/(protected)/workorders/_components/work-order-form.tsx index 35e3afa7..eddb9511 100644 --- a/src/app/(protected)/workorders/_components/work-order-form.tsx +++ b/src/app/(protected)/workorders/_components/work-order-form.tsx @@ -3,8 +3,9 @@ import { useQuery } from "@tanstack/react-query"; import { useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Link as RouterLink, useNavigate, useParams, useSearchParams } from "react-router"; -import { Box, Breadcrumbs, Button, CircularProgress, Link, Paper, Stack } from "@mui/material"; +import { Box, Breadcrumbs, Button, Link, Paper, Stack } from "@mui/material"; import { Text } from "@/components/ui/text"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api"; import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper"; @@ -28,6 +29,7 @@ import { WorkOrderDetailsFields, } from "@/app/(protected)/workorders/_components/work-order-fields"; import { VendorAndTimeSection } from "@/app/(protected)/workorders/_components/vendor-assignment-section"; +import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton"; type ScheduleBounds = { kind: "valid" | "invalid" | "open"; @@ -125,7 +127,12 @@ function useWorkOrderFormState() { const isEdit = Boolean(id); const assignVendorRequested = searchParams.get("assignVendor") === "1"; - const { data: detail, isLoading: isLoadingDetail, error: detailError } = useWorkOrderDetail(id); + const { + data: detail, + isLoading: isLoadingDetail, + error: detailError, + refetch: refetchDetail, + } = useWorkOrderDetail(id); const { data: locations = [], isLoading: isLoadingLocations } = useWorkOrderLocations(); const { data: employees = [] } = useWorkOrderEmployeesDropdown(); const { data: problems = [] } = useDropdownOptionsByCategory("Problem"); @@ -225,6 +232,7 @@ function useWorkOrderFormState() { detail, isLoading, detailError, + refetchDetail, isSaving: saveMutation.isPending, register, control, @@ -264,6 +272,7 @@ export default function WorkOrderFormPage() { detail, isLoading, detailError, + refetchDetail, isSaving, register, control, @@ -296,17 +305,16 @@ export default function WorkOrderFormPage() { } = useWorkOrderFormState(); if (isLoading) { - return ( - - - - ); + return ; } if (detailError) { return ( - Unable to load this work order. Please try again. + void refetchDetail()} + /> ); } diff --git a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts index c47021a3..3c4cd207 100644 --- a/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts +++ b/src/domain/work-orders/mappers/assign-wizard-schedule-fields.ts @@ -2,7 +2,11 @@ import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/wor import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard"; import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils"; -/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */ +/** + * Mutates `payload` with `dueDate` and schedule fields. + * Range mode lands on the board using the range start as `scheduledDate` + * (prototype `hasScheduleDate`: a range start qualifies as scheduled). + */ export function assignWizardScheduleFields( payload: WorkOrderBoardCreatePayload, draft: WorkOrderWizardDraft, @@ -21,7 +25,7 @@ export function assignWizardScheduleFields( payload.scheduleWeekOnly = true; payload.targetWeek = week; } - } else if (!isRange && draft.scheduledOn) { + } else if (draft.scheduledOn && (isRange || !draft.scheduleWeekOnly)) { payload.scheduledDate = draft.scheduledOn; } } diff --git a/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx index e1e6acb5..32f76148 100644 --- a/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx +++ b/src/test/app/(protected)/workorders/new-wo-wizard-duplicate.test.tsx @@ -15,10 +15,13 @@ vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () = })); vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({ - useWorkOrderLocations: () => ({ data: [] }), useWorkOrderVendors: () => ({ data: [] }), })); +vi.mock("@/domain/locations/use-cases/use-location-sites", () => ({ + useLocationSites: () => ({ data: [] }), +})); + vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({ useLocationDetail: () => ({ data: undefined }), })); @@ -45,7 +48,7 @@ describe("NewWoWizard duplicate wo number", () => { />, ); - const woNumberInput = screen.getByPlaceholderText("e.g. 24818"); + const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901"); fireEvent.change(woNumberInput, { target: { value: "12345678901" }, }); @@ -74,7 +77,7 @@ describe("NewWoWizard duplicate wo number", () => { />, ); - const woNumberInput = screen.getByPlaceholderText("e.g. 24818"); + const woNumberInput = screen.getByPlaceholderText("e.g. 12345678901"); fireEvent.change(woNumberInput, { target: { value: "98765432109" }, }); diff --git a/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx b/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx new file mode 100644 index 00000000..5f9113b8 --- /dev/null +++ b/src/test/app/(protected)/workorders/use-wizard-poc-autofill.test.tsx @@ -0,0 +1,78 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill"; +import type { Location } from "@/domain/locations/types/location"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { useState } from "react"; + +function baseLocation(overrides: Partial = {}): Location { + return { + id: "loc-1", + name: "BK5", + contact: "Janelle Pickett", + phone: "214-555-0111", + ...overrides, + } as Location; +} + +describe("useWizardPocAutofill", () => { + it("fills empty POC fields from location detail once", () => { + const { result } = renderHook(() => { + const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT }); + useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft); + return draft; + }); + + expect(result.current.pocName).toBe("Janelle Pickett"); + expect(result.current.pocPhone).toBe("214-555-0111"); + }); + + it("does not overwrite POC edits made before location detail arrives", () => { + const { result, rerender } = renderHook( + ({ detail }: { detail: Location | undefined }) => { + const [draft, setDraft] = useState({ + ...EMPTY_WIZARD_DRAFT, + pocName: "Dispatcher Typed", + pocPhone: "999-000-1111", + }); + useWizardPocAutofill(true, "loc-1", detail, setDraft); + return draft; + }, + { initialProps: { detail: undefined as Location | undefined } }, + ); + + expect(result.current.pocName).toBe("Dispatcher Typed"); + + act(() => { + rerender({ detail: baseLocation() }); + }); + + expect(result.current.pocName).toBe("Dispatcher Typed"); + expect(result.current.pocPhone).toBe("999-000-1111"); + }); + + it("re-seeds POC after re-selecting the same location clears the fields", () => { + const { result } = renderHook(() => { + const [draft, setDraft] = useState({ ...EMPTY_WIZARD_DRAFT }); + const autofill = useWizardPocAutofill(true, "loc-1", baseLocation(), setDraft); + return { draft, setDraft, autofill }; + }); + + expect(result.current.draft.pocName).toBe("Janelle Pickett"); + + act(() => { + result.current.setDraft((current) => ({ + ...current, + pocName: "", + pocPhone: "", + })); + result.current.autofill.notifyLocationChanged("loc-1"); + }); + + expect(result.current.draft.pocName).toBe("Janelle Pickett"); + expect(result.current.draft.pocPhone).toBe("214-555-0111"); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx new file mode 100644 index 00000000..7ef8223c --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx @@ -0,0 +1,110 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils"; + +function baseDraft(overrides: Partial = {}): WorkOrderWizardDraft { + return { ...EMPTY_WIZARD_DRAFT, type: "PM", ...overrides }; +} + +function currentMonthIso(day: number): string { + const [y, m] = todayIso().split("-").map(Number); + return `${y}-${pad2(m)}-${pad2(day)}`; +} + +async function openScheduleRangePicker() { + fireEvent.click(screen.getByRole("button", { name: /pick a date/i })); + const rangeTab = await screen.findByRole("button", { name: "Range" }); + fireEvent.click(rangeTab); +} + +describe("WizardStepTypeSchedule — Schedule On range selection", () => { + it("clicks start then end in order and submits schedMode range with matching dates", async () => { + const onUpdate = vi.fn(); + const onPatch = vi.fn(); + + render( + , + ); + + await openScheduleRangePicker(); + + const startIso = currentMonthIso(5); + const endIso = currentMonthIso(20); + + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: "20" })); + fireEvent.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onPatch).toHaveBeenCalledWith({ + scheduledOn: startIso, + scheduleEnd: endIso, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }); + }); + + it("normalizes an out-of-order click (end clicked before start) into start { + const onUpdate = vi.fn(); + const onPatch = vi.fn(); + + render( + , + ); + + await openScheduleRangePicker(); + + const laterIso = currentMonthIso(20); + const earlierIso = currentMonthIso(5); + + // User clicks the later date first, then an earlier date — the field must + // normalize this into (start = earlier, end = later) before submission. + fireEvent.click(screen.getByRole("button", { name: "20" })); + fireEvent.click(screen.getByRole("button", { name: "5" })); + fireEvent.click(screen.getByRole("button", { name: /^done$/i })); + + expect(onPatch).toHaveBeenCalledWith({ + scheduledOn: earlierIso, + scheduleEnd: laterIso, + schedMode: "range", + scheduleWeekOnly: false, + targetWeek: "", + }); + }); + + it("does not submit until both a start and end date are picked", async () => { + const onUpdate = vi.fn(); + const onPatch = vi.fn(); + + render( + , + ); + + await openScheduleRangePicker(); + + fireEvent.click(screen.getByRole("button", { name: "5" })); + + expect(screen.getByRole("button", { name: /^done$/i })).toBeDisabled(); + expect(onPatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx new file mode 100644 index 00000000..3812fb63 --- /dev/null +++ b/src/test/app/(protected)/workorders/wizard-service-clears-vendor.test.tsx @@ -0,0 +1,88 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WizardStepLocationServiceSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service-select"; +import { + EMPTY_WIZARD_DRAFT, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +describe("WizardStepLocationServiceSelect — vendor clear on service change", () => { + it("clears vendor fields when the primary service changes", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + pm: "HVAC", + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "HVAC" })); + fireEvent.click(screen.getByRole("button", { name: "Plumbing" })); + + expect(onPatch).toHaveBeenCalledWith( + expect.objectContaining({ + pm: "Plumbing", + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + }), + ); + }); + + it("replaces the selected service in Service & Notes without persisting extraServices", () => { + const onPatch = vi.fn(); + const draft: WorkOrderWizardDraft = { + ...EMPTY_WIZARD_DRAFT, + pm: "HVAC", + extraServices: ["Plumbing"], + vendorId: "9", + vendorName: "Old Vendor", + techPhone: "555-0100", + }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Electrical" })); + + expect(onPatch).toHaveBeenCalledWith({ + pm: "Electrical", + extraServices: [], + vendorId: "", + vendorName: "", + techPhone: "", + }); + }); +}); 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 bce8e5dc..f6d20225 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 @@ -273,7 +273,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { ).toThrow("Site code is required"); }); - it("omits scheduledDate for range schedule and puts range in description only", () => { + it("sets scheduledDate from range start and keeps range text in description", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT, @@ -292,7 +292,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { vendorNotes: "Also not in description", }); - expect(payload.scheduledDate).toBeUndefined(); + expect(payload.scheduledDate).toBe("2026-06-08"); expect(payload.scheduleWeekOnly).toBeUndefined(); @@ -313,7 +313,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => { expect(payload.vendorNotes).toBe("Also not in description"); }); - it("maps service notes and extras as structured fields instead of description", () => { + it("maps service notes as structured fields and extraServices without duplicates", () => { const payload = mapWizardDraftToBoardCreatePayload({ ...EMPTY_WIZARD_DRAFT,