diff --git a/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx new file mode 100644 index 00000000..f636c2c7 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/new-wo-wizard.tsx @@ -0,0 +1,227 @@ +import { useEffect, useMemo, useState } from "react"; +import { Dialog } from "@mui/material"; +import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop"; +import { Text } from "@/components/ui/text"; +import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category"; +import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper"; +import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled"; +import { + getMockWoWizardLocationDetail, + MOCK_WO_WIZARD_LOCATIONS, + MOCK_WO_WIZARD_SERVICES, + MOCK_WO_WIZARD_USERS, + MOCK_WO_WIZARD_VENDORS, +} from "@/domain/work-orders/mocks/wo-wizard-mock-data"; +import type { WorkOrderUser } from "@/domain/work-orders/types/work-order"; +import { + EMPTY_WIZARD_DRAFT, + WIZARD_STEPS, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; +import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail"; +import { + useWorkOrderLocations, + 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 { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer"; +import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar"; +import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service"; +import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule"; +import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time"; + +type NewWoWizardProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + users: WorkOrderUser[]; +}; + +function WizardStepHeader({ step }: { step: number }) { + return ( +
+
+ Step {step} of {WIZARD_STEPS.length} +
+ + {WIZARD_STEPS[step - 1].title} + + + {WIZARD_STEPS[step - 1].subtitle} + +
+ ); +} + +export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) { + const mockFallback = isWoWizardMockEnabled(); + const [step, setStep] = useState(1); + const [draft, setDraft] = useState({ + ...EMPTY_WIZARD_DRAFT, + status: "Incomplete", + }); + const [pocAutoFilledFor, setPocAutoFilledFor] = useState(""); + + const { data: apiLocations = [] } = useWorkOrderLocations(); + const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem"); + const isMockLocation = draft.locationId.startsWith("mock-"); + const { data: apiLocationDetail } = useLocationDetail( + isMockLocation ? undefined : draft.locationId || undefined, + ); + const mockLocationDetail = isMockLocation + ? getMockWoWizardLocationDetail(draft.locationId) + : undefined; + const locationDetail = mockLocationDetail ?? apiLocationDetail; + const { data: apiVendors = [] } = useWorkOrderVendors( + undefined, + isMockLocation ? undefined : locationDetail?.zipCode, + ); + const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false)); + + const wizardUsers = useMemo(() => { + if (users.length > 0) { + return users; + } + return mockFallback ? MOCK_WO_WIZARD_USERS : []; + }, [mockFallback, users]); + const locations = + apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : []; + const problems = + apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : []; + const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : []; + + useEffect(() => { + if (open) { + setStep(1); + setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" }); + setPocAutoFilledFor(""); + } + }, [open]); + + useEffect(() => { + if (!draft.locationId || !locationDetail) { + return; + } + if (pocAutoFilledFor === draft.locationId) { + return; + } + setDraft((current) => ({ + ...current, + pocName: locationDetail.contact ?? current.pocName, + pocPhone: locationDetail.phone ?? current.pocPhone, + })); + setPocAutoFilledFor(draft.locationId); + }, [draft.locationId, locationDetail, pocAutoFilledFor]); + + const setField = ( + key: K, + value: WorkOrderWizardDraft[K], + ) => { + setDraft((current) => ({ ...current, [key]: value })); + }; + + const patchDraft = (patch: Partial) => { + setDraft((current) => ({ ...current, ...patch })); + if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) { + setPocAutoFilledFor(""); + } + }; + + const handleCreate = () => createMutation.mutate(mapWizardDraftToFormValues(draft)); + + return ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "flex flex-col overflow-hidden p-0", + style: { + borderRadius: 12, + width: 768, + maxWidth: 768, + height: 680, + maxHeight: 680, + }, + }, + }} + > + Guided setup for creating a new work order + +
+ + +
+ + + {step === 1 && ( + + )} + + {step === 2 && ( + ({ id: p.id, value: p.value }))} + onUpdate={setField} + onPatch={patchDraft} + /> + )} + + {step === 3 && ( + + )} +
+
+ + onOpenChange(false)} + onBack={() => setStep((s) => s - 1)} + onContinue={() => setStep((s) => s + 1)} + onCreate={handleCreate} + isCreating={createMutation.isPending} + /> +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx new file mode 100644 index 00000000..e4d38cd2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-date-field.tsx @@ -0,0 +1,145 @@ +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 { useMenuAnchor } from "@/hooks/use-menu-anchor"; + +type WizardDateFieldProps = { + value: string; + placeholder: string; + onChange: (iso: string) => void; + allowWeekMode?: boolean; + targetWeek?: string; + onChangeWeek?: (weekStart: string) => void; +}; + +export function WizardDateField({ + value, + placeholder, + onChange, + allowWeekMode, + targetWeek = "", + onChangeWeek, +}: WizardDateFieldProps) { + const { anchorEl, open, setAnchor, close } = useMenuAnchor(); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode && !value && targetWeek ? "week" : "date", + ); + const showWeek = Boolean(allowWeekMode && !value && targetWeek); + + 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} + + + + + e.stopPropagation(), + }, + }} + > + {Boolean(allowWeekMode) && ( +
+ {(["week", "date"] as const).map((m) => { + const activeMode = cellMode === m; + return ( + + ); + })} +
+ )} + + {allowWeekMode && cellMode === "week" ? ( + { + onChange(""); + onChangeWeek?.(mon); + close(); + }} + /> + ) : ( + { + onChange(iso); + close(); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx new file mode 100644 index 00000000..4240f19a --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-footer.tsx @@ -0,0 +1,110 @@ +import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + WIZARD_STEPS, + canContinueWizardStep1, + canCreateWorkOrderFromWizard, + isWizardStepValid, + type WorkOrderWizardDraft, +} from "@/domain/work-orders/types/work-order-wizard"; + +type WizardFooterProps = { + step: number; + draft: WorkOrderWizardDraft; + onCancel: () => void; + onBack: () => void; + onContinue: () => void; + onCreate: () => void; + isCreating: boolean; +}; + +export function WizardFooter({ + step, + draft, + onCancel, + onBack, + onContinue, + onCreate, + isCreating, +}: WizardFooterProps) { + const stepValid = isWizardStepValid(step, draft); + const canCreate = canCreateWorkOrderFromWizard(draft); + const canContinue1 = canContinueWizardStep1(draft); + + return ( +
+ {step === 1 ? ( + + ) : ( + + )} + +
+ {step === 1 && !canContinue1 && ( + + Pick a type to continue + + )} +
+ + {step < WIZARD_STEPS.length ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx new file mode 100644 index 00000000..62a13a75 --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx @@ -0,0 +1,225 @@ +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"; + +type WizardSidebarProps = { + step: number; + draft: WorkOrderWizardDraft; +}; + +export function WizardSidebar({ step, draft }: WizardSidebarProps) { + return ( +
+
+ + + +
+
+ Operations +
+
+ New work order +
+
+
+ +
+ {WIZARD_STEPS.map((s, i) => { + const n = i + 1; + const done = n < step; + const active = n === step; + const last = i === WIZARD_STEPS.length - 1; + return ( +
+
+ + {!last && ( +
+ )} +
+
+
+ {s.title} +
+
+ {s.nav} +
+
+
+ ); + })} +
+ + +
+ ); +} + +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-location-service.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx new file mode 100644 index 00000000..28a19d9b --- /dev/null +++ b/src/app/(protected)/workorders/_components/wizard/wizard-step-location-service.tsx @@ -0,0 +1,180 @@ +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"; + +type ServiceOption = { id: string | number; value: string }; + +type WizardStepLocationServiceProps = { + draft: WorkOrderWizardDraft; + locations: LocationOption[]; + locationDetail: Location | undefined; + services: ServiceOption[]; + 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, + services, + onUpdate, + onPatch, +}: WizardStepLocationServiceProps) { + 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 !== "" && ( + <> +
+ + {Boolean(locationDetail.phone) && ( + + )} + {Boolean(locationDetail.contactEmail) && ( + + )} + {Boolean(locationDetail.address) && ( + + Open in Google Maps + + )} +
+
+ Point of contact + + Auto-filled from records — edit if outdated + +
+ onUpdate("pocName", e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + onUpdate("pocPhone", e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +
+