({ id: p.id, value: p.value }))}
+ svcOpen={wizard.svcOpen}
+ onSvcOpenChange={wizard.setSvcOpen}
+ svcSearch={wizard.svcSearch}
+ onSvcSearchChange={wizard.setSvcSearch}
onUpdate={wizard.setField}
onPatch={wizard.patchDraft}
/>
@@ -135,7 +105,9 @@ export function NewWoWizard({
onOpenChange(false)}
+ onCancel={() => {
+ if (!wizard.isCreating) onOpenChange(false);
+ }}
onBack={() => wizard.setStep((s) => s - 1)}
onContinue={() => wizard.setStep((s) => s + 1)}
onCreate={wizard.handleCreate}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
new file mode 100644
index 00000000..908c6d1a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-controller.ts
@@ -0,0 +1,93 @@
+import { useEffect } from "react";
+
+import { guardDialogClose } from "@/lib/dialog-guard";
+
+import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
+
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+
+import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
+import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
+
+import { useWorkOrderVendors } from "@/domain/work-orders/use-cases/use-work-order-detail";
+import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
+
+import { useNewWoWizardDraft } from "@/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft";
+import { useWizardDuplicateActions } from "@/app/(protected)/workorders/_components/wizard/use-wizard-duplicate-actions";
+import { useWizardPocAutofill } from "@/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill";
+
+type UseNewWoWizardControllerArgs = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ existingRows: WorkOrderTableRow[];
+};
+
+/** Wires draft state, lookups, POC autofill, and duplicate-WO guards for the create wizard. */
+export function useNewWoWizardController({
+ open,
+ onOpenChange,
+ existingRows,
+}: UseNewWoWizardControllerArgs) {
+ const draftState = useNewWoWizardDraft(open);
+
+ const { data: locations = [] } = useLocationSites();
+ const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
+ const {
+ data: locationDetail,
+ isLoading: locationDetailLoading,
+ isError: locationDetailError,
+ } = useLocationDetail(draftState.draft.locationId || undefined);
+ const { data: vendors = [] } = useWorkOrderVendors(
+ draftState.draft.pm.trim() || undefined,
+ locationDetail?.zipCode,
+ );
+
+ const { notifyLocationChanged } = useWizardPocAutofill(
+ open,
+ draftState.draft.locationId,
+ locationDetail,
+ draftState.setDraft,
+ );
+
+ const patchDraft = (patch: Partial) => {
+ draftState.patchDraft(patch);
+ if (patch.locationId !== undefined) {
+ notifyLocationChanged(patch.locationId);
+ }
+ };
+
+ const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
+ const { duplicateRow, setDuplicateRow, handleDuplicateFound, handleCreate, isCreating } =
+ useWizardDuplicateActions({
+ open,
+ draft: draftState.draft,
+ existingRows,
+ createMutation,
+ });
+
+ useEffect(() => {
+ if (open) {
+ setDuplicateRow(null);
+ }
+ }, [open, setDuplicateRow]);
+
+ const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
+
+ return {
+ ...draftState,
+ patchDraft,
+ locations,
+ problems,
+ locationDetail,
+ locationDetailLoading,
+ locationDetailError,
+ vendors,
+ duplicateRow,
+ setDuplicateRow,
+ handleDuplicateFound,
+ handleCreate,
+ isCreating,
+ handleClose,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts
new file mode 100644
index 00000000..a3f6a348
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-new-wo-wizard-draft.ts
@@ -0,0 +1,49 @@
+import { useEffect, useState } from "react";
+import {
+ EMPTY_WIZARD_DRAFT,
+ type WorkOrderWizardDraft,
+} from "@/domain/work-orders/types/work-order-wizard";
+
+/** Owns the step/draft/service-panel state for the new-work-order wizard, resetting on open. */
+export function useNewWoWizardDraft(open: boolean) {
+ const [step, setStep] = useState(1);
+ const [draft, setDraft] = useState({
+ ...EMPTY_WIZARD_DRAFT,
+ status: "Incomplete",
+ });
+ const [svcOpen, setSvcOpen] = useState(false);
+ const [svcSearch, setSvcSearch] = useState("");
+
+ useEffect(() => {
+ if (open) {
+ setStep(1);
+ setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
+ setSvcOpen(false);
+ setSvcSearch("");
+ }
+ }, [open]);
+
+ const setField = (
+ key: K,
+ value: WorkOrderWizardDraft[K],
+ ) => {
+ setDraft((current) => ({ ...current, [key]: value }));
+ };
+
+ const patchDraft = (patch: Partial) => {
+ setDraft((current) => ({ ...current, ...patch }));
+ };
+
+ return {
+ step,
+ setStep,
+ draft,
+ setDraft,
+ setField,
+ patchDraft,
+ svcOpen,
+ setSvcOpen,
+ svcSearch,
+ setSvcSearch,
+ };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts
new file mode 100644
index 00000000..ae48b4b5
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state.ts
@@ -0,0 +1,87 @@
+import { useEffect, useState } from "react";
+import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
+
+type UseWizardDateFieldStateArgs = {
+ open: boolean;
+ value: string;
+ scheduleEnd: string;
+ allowRange: boolean;
+ allowWeekMode?: boolean;
+ targetWeek: string;
+ schedMode?: WizardSchedMode;
+};
+
+function resolveInitialMode({
+ allowRange,
+ schedMode,
+ allowWeekMode,
+ value,
+ targetWeek,
+}: Pick<
+ UseWizardDateFieldStateArgs,
+ "allowRange" | "schedMode" | "allowWeekMode" | "value" | "targetWeek"
+>): WizardSchedMode {
+ if (allowRange && schedMode === "range") {
+ return "range";
+ }
+ if (allowWeekMode && !value && targetWeek) {
+ return "week";
+ }
+ return "date";
+}
+
+/** Owns the popover-local mode/range selection state for `WizardDateField`. */
+export function useWizardDateFieldState({
+ open,
+ value,
+ scheduleEnd,
+ allowRange,
+ allowWeekMode,
+ targetWeek,
+ schedMode,
+}: UseWizardDateFieldStateArgs) {
+ const initialMode = resolveInitialMode({
+ allowRange,
+ schedMode,
+ allowWeekMode,
+ value,
+ targetWeek,
+ });
+
+ const [cellMode, setCellMode] = useState(initialMode);
+ const [rangeStart, setRangeStart] = useState(value || "");
+ const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ // Keep draft props as source of truth while open so parent clears
+ // (e.g. Clear schedule) cannot leave stale rangeStart/rangeEnd.
+ setRangeStart(value || "");
+ setRangeEnd(scheduleEnd || "");
+ }, [open, value, scheduleEnd]);
+
+ useEffect(() => {
+ if (open) {
+ setCellMode(initialMode);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reseat mode only when the popover opens
+ }, [open]);
+
+ function handleRangeClick(iso: string) {
+ if (!rangeStart || (rangeStart && rangeEnd)) {
+ setRangeStart(iso);
+ setRangeEnd("");
+ return;
+ }
+ if (iso < rangeStart) {
+ setRangeStart(iso);
+ setRangeEnd(rangeStart);
+ } else {
+ setRangeEnd(iso);
+ }
+ }
+
+ return { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill.ts b/src/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill.ts
new file mode 100644
index 00000000..0b515cac
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/use-wizard-poc-autofill.ts
@@ -0,0 +1,51 @@
+import { useEffect, useState } from "react";
+import type { Location } from "@/domain/locations/types/location";
+import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+
+type SetDraft = (updater: (current: WorkOrderWizardDraft) => WorkOrderWizardDraft) => void;
+
+/**
+ * Auto-fills POC name/phone from the fetched location, once per selected location.
+ * Never clobbers fields the dispatcher already typed while detail was loading
+ * (prototype: autofill from site records, then edit if outdated).
+ */
+export function useWizardPocAutofill(
+ open: boolean,
+ locationId: string,
+ locationDetail: Location | undefined,
+ setDraft: SetDraft,
+) {
+ const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
+
+ useEffect(() => {
+ if (open) {
+ setPocAutoFilledFor("");
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!locationId || !locationDetail || pocAutoFilledFor === locationId) {
+ return;
+ }
+ setDraft((current) => ({
+ ...current,
+ pocName: current.pocName.trim()
+ ? current.pocName
+ : (locationDetail.contact ?? current.pocName),
+ pocPhone: current.pocPhone.trim()
+ ? current.pocPhone
+ : (locationDetail.phone ?? current.pocPhone),
+ }));
+ setPocAutoFilledFor(locationId);
+ }, [locationId, locationDetail, pocAutoFilledFor, setDraft]);
+
+ // Always clear the seed guard on a site pick (including re-selecting the
+ // same location). Prototype onPick re-seeds pocName/pocPhone from the site
+ // every time; if we keep the guard keyed by the same id, clearing the fields
+ // leaves them blank because autofill will not re-run.
+ function notifyLocationChanged(_newLocationId: string) {
+ setPocAutoFilledFor("");
+ }
+
+ return { notifyLocationChanged };
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger.tsx
new file mode 100644
index 00000000..1b7cb1af
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger.tsx
@@ -0,0 +1,68 @@
+import { CalendarDays, CalendarIcon, CalendarRange, ChevronDown } from "lucide-react";
+import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+
+type WizardDateFieldTriggerProps = {
+ onAnchor: (element: HTMLElement) => void;
+ open: boolean;
+ label: string;
+ showWeek: boolean;
+ showRange: boolean;
+ hasValue: boolean;
+};
+
+function DateFieldIcon({ showRange, showWeek }: { showRange: boolean; showWeek: boolean }) {
+ const style = { color: "var(--muted-foreground)" };
+ if (showRange) {
+ return ;
+ }
+ if (showWeek) {
+ return ;
+ }
+ return ;
+}
+
+export function WizardDateFieldTrigger({
+ onAnchor,
+ open,
+ label,
+ showWeek,
+ showRange,
+ hasValue,
+}: WizardDateFieldTriggerProps) {
+ return (
+ {
+ (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
+ }}
+ onBlur={(e) => {
+ (e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
+ }}
+ aria-haspopup="dialog"
+ >
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx
index e4d38cd2..80a9ae90 100644
--- a/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx
@@ -1,10 +1,12 @@
-import { useState } from "react";
-import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
-import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
-import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+import { WizardDateFieldTrigger } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field-trigger";
+import { WizardDateModeTabs } from "@/app/(protected)/workorders/_components/wizard/wizard-date-mode-tabs";
+import { WizardDateRangePanel } from "@/app/(protected)/workorders/_components/wizard/wizard-date-range-panel";
+import { useWizardDateFieldState } from "@/app/(protected)/workorders/_components/wizard/use-wizard-date-field-state";
+import type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
+import { fmtDate, fmtDateRange, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WizardDateFieldProps = {
@@ -14,8 +16,29 @@ type WizardDateFieldProps = {
allowWeekMode?: boolean;
targetWeek?: string;
onChangeWeek?: (weekStart: string) => void;
+ scheduleEnd?: string;
+ schedMode?: WizardSchedMode;
+ onChangeRange?: (start: string, end: string) => void;
};
+function fieldLabel(args: {
+ showRange: boolean;
+ showWeek: boolean;
+ value: string;
+ scheduleEnd: string;
+ targetWeek: string;
+ placeholder: string;
+}): string {
+ const { showRange, showWeek, value, scheduleEnd, targetWeek, placeholder } = args;
+ if (showRange) {
+ return fmtDateRange(value, scheduleEnd);
+ }
+ if (showWeek) {
+ return fmtWeekRange(targetWeek);
+ }
+ return value ? fmtDate(value) : placeholder;
+}
+
export function WizardDateField({
value,
placeholder,
@@ -23,60 +46,44 @@ export function WizardDateField({
allowWeekMode,
targetWeek = "",
onChangeWeek,
+ scheduleEnd = "",
+ schedMode,
+ onChangeRange,
}: WizardDateFieldProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
- const [cellMode, setCellMode] = useState<"week" | "date">(
- allowWeekMode && !value && targetWeek ? "week" : "date",
+ const allowRange = Boolean(allowWeekMode && onChangeRange);
+
+ const { cellMode, setCellMode, rangeStart, rangeEnd, handleRangeClick } = useWizardDateFieldState(
+ {
+ open,
+ value,
+ scheduleEnd,
+ allowRange,
+ allowWeekMode,
+ targetWeek,
+ schedMode,
+ },
);
+
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
+ const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
+
+ const modes: WizardSchedMode[] = allowRange
+ ? ["week", "date", "range"]
+ : allowWeekMode
+ ? ["week", "date"]
+ : [];
return (
<>
- {
- (e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
- }}
- onBlur={(e) => {
- (e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
- }}
- aria-haspopup="dialog"
- >
- {showWeek ? (
-
- ) : (
-
- )}
-
- {showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder}
-
-
-
+ label={fieldLabel({ showRange, showWeek, value, scheduleEnd, targetWeek, placeholder })}
+ showWeek={showWeek}
+ showRange={showRange}
+ hasValue={Boolean(value)}
+ />
- {Boolean(allowWeekMode) && (
-
- {(["week", "date"] as const).map((m) => {
- const activeMode = cellMode === m;
- 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..5295cc69
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar-summary-rows.ts
@@ -0,0 +1,45 @@
+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;
+}
+
+/** 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)],
+ ["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 62a13a75..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,106 +111,3 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
);
}
-
-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
- : "—",
- ],
- ["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..3b3b1e3a
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-poc-fields.tsx
@@ -0,0 +1,51 @@
+import { Text } from "@/components/ui/text";
+import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
+import {
+ DLG_INPUT_CLS,
+ DLG_INPUT_STYLE,
+} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
+import { WizSubLabel } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
+
+type WizardStepLocationPocFieldsProps = {
+ draft: WorkOrderWizardDraft;
+ onUpdate:
(key: K, value: WorkOrderWizardDraft[K]) => void;
+};
+
+export function WizardStepLocationPocFields({ draft, onUpdate }: WizardStepLocationPocFieldsProps) {
+ return (
+
+ );
+}
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 28a19d9b..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,18 +1,11 @@
-import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
-import { 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 {
- Detail,
- WizLabel,
- WizSubLabel,
-} 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 };
@@ -20,151 +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 ?? "",
- });
- }}
- />
- {!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
-
- )}
-
-
+
+
>
)}
-
- 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..8d06d966
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-site-select.tsx
@@ -0,0 +1,60 @@
+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: "",
+ });
+ }}
+ />
+ {!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 8e54348b..b278faa1 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";
@@ -119,6 +120,43 @@ type WizardStepTypeScheduleProps = {
onDuplicateWoNumber?: (woNumber: string) => void;
};
+function hasWizardSchedule(draft: WorkOrderWizardDraft): boolean {
+ return draft.scheduledOn !== "" || Boolean(draft.scheduleWeekOnly) || Boolean(draft.scheduleEnd);
+}
+
+function clearSchedulePatch(): Partial {
+ return {
+ scheduledOn: "",
+ scheduleWeekOnly: false,
+ scheduleEnd: "",
+ schedMode: undefined,
+ targetWeek: "",
+ isAddOnOverride: false,
+ };
+}
+
+function WizardAddOnOverrideField({
+ checked,
+ onChange,
+}: {
+ checked: boolean;
+ onChange: (value: boolean) => void;
+}) {
+ return (
+
+ );
+}
+
export function WizardStepTypeSchedule({
draft,
users,
@@ -126,6 +164,8 @@ export function WizardStepTypeSchedule({
onPatch,
onDuplicateWoNumber,
}: WizardStepTypeScheduleProps) {
+ const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
+
return (
@@ -137,10 +177,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}
@@ -153,13 +194,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);
@@ -175,23 +220,24 @@ export function WizardStepTypeSchedule({
onPatch({ scheduledOn: "", scheduleWeekOnly: false })
- : undefined
- }
+ onClear={hasWizardSchedule(draft) ? () => onPatch(clearSchedulePatch()) : undefined}
>
Schedule On
onPatch({
scheduledOn: iso,
scheduleWeekOnly: false,
+ scheduleEnd: "",
+ schedMode: "date",
+ targetWeek: "",
isAddOnOverride: false,
})
}
@@ -199,6 +245,19 @@ export function WizardStepTypeSchedule({
onPatch({
scheduledOn: w,
scheduleWeekOnly: Boolean(w),
+ scheduleEnd: "",
+ schedMode: "week",
+ targetWeek: w,
+ isAddOnOverride: false,
+ })
+ }
+ onChangeRange={(s, e) =>
+ onPatch({
+ scheduledOn: s,
+ scheduleEnd: e,
+ schedMode: "range",
+ scheduleWeekOnly: false,
+ targetWeek: "",
isAddOnOverride: false,
})
}
@@ -221,19 +280,11 @@ export function WizardStepTypeSchedule({
- {/* Manual Add-On only when there is no schedule date (cutoff needs a week). */}
- {Boolean(!draft.scheduledOn) && (
-
+ {Boolean(!hasWizardSchedule(draft)) && (
+
onUpdate("isAddOnOverride", value)}
+ />
)}
);
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/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
index 66d906bd..cdac7c9b 100644
--- a/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
+++ b/src/domain/work-orders/mappers/assign-wizard-service-poc-fields.ts
@@ -7,42 +7,7 @@ function trimOrUndefined(value: string | undefined): string | undefined {
return trimmed ? trimmed : undefined;
}
-function normalizeExtraServices(
- primary: string | undefined,
- extras: string[],
-): string[] | undefined {
- if (extras.length === 0) {
- return undefined;
- }
-
- const seen = new Set();
-
- const primaryKey = primary?.toLowerCase() ?? "";
-
- const out: string[] = [];
-
- for (const raw of extras) {
- const trimmed = raw.trim();
-
- if (!trimmed) {
- continue;
- }
-
- const key = trimmed.toLowerCase();
-
- if (key === primaryKey || seen.has(key)) {
- continue;
- }
-
- seen.add(key);
-
- out.push(trimmed);
- }
-
- return out.length > 0 ? out : undefined;
-}
-
-/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */
+/** Mutates `payload` with primary service, service notes, POC, tech phone, and vendor notes. */
export function assignWizardServicePocFields(
payload: WorkOrderBoardCreatePayload,
draft: WorkOrderWizardDraft,
@@ -53,12 +18,6 @@ export function assignWizardServicePocFields(
payload.primaryService = primaryService;
}
- const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
-
- if (extraServices) {
- payload.extraServices = extraServices;
- }
-
const serviceNotes = trimOrUndefined(draft.pmNote);
if (serviceNotes) {
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..a79ac2e6
--- /dev/null
+++ b/src/test/app/(protected)/workorders/wizard-schedule-date-range.test.tsx
@@ -0,0 +1,112 @@
+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: "",
+ isAddOnOverride: false,
+ });
+ });
+
+ 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: "",
+ isAddOnOverride: false,
+ });
+ });
+
+ 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 f295d012..729f016f 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
@@ -85,7 +85,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.isAddOn).toBe(true);
});
- it("derives isAddOn from range start via cutoff (range omits scheduledDate on create)", () => {
+ it("derives isAddOn from range start via cutoff (range persists scheduledDate)", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
type: "PM",
@@ -96,8 +96,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
isAddOnOverride: false,
});
- // On create, range mode does not persist scheduledDate (notes carry the range).
- expect(payload.scheduledDate).toBeUndefined();
+ expect(payload.scheduledDate).toBe("2026-07-08");
// createdAt defaults to now; after prior-Saturday 2026-07-04 for that week → true
expect(payload.isAddOn).toBe(true);
});
@@ -182,8 +181,6 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
primaryService: "HVAC PM",
- extraServices: ["Filter change", "Coil clean"],
-
serviceNotes: "Check unit on roof",
pocName: "Jane Site Lead",
@@ -201,6 +198,8 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.trade).toBeUndefined();
+ expect(payload.extraServices).toBeUndefined();
+
expect(payload.description).toBeUndefined();
expect(payload.scheduleWeekOnly).toBeUndefined();
@@ -329,7 +328,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,
@@ -348,7 +347,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
vendorNotes: "Also not in description",
});
- expect(payload.scheduledDate).toBeUndefined();
+ expect(payload.scheduledDate).toBe("2026-06-08");
expect(payload.scheduleWeekOnly).toBeUndefined();
@@ -369,7 +368,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 omits extraServices from wizard create", () => {
const payload = mapWizardDraftToBoardCreatePayload({
...EMPTY_WIZARD_DRAFT,
@@ -386,7 +385,7 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
expect(payload.primaryService).toBe("HVAC PM");
- expect(payload.extraServices).toEqual(["Filter change", "Coil clean"]);
+ expect(payload.extraServices).toBeUndefined();
expect(payload.serviceNotes).toBe("Check unit on roof");