From 27131a15331f882e58b7984b29fc09d0498a303a Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Mon, 27 Jul 2026 13:38:53 -0300 Subject: [PATCH] feat(work-orders): board wizard create with sites and service notes --- .../_components/wizard/mini-calendar.tsx | 69 +++++-- .../_components/wizard/new-wo-wizard.tsx | 95 +++++----- .../_components/wizard/wizard-date-field.tsx | 136 +++++++++++-- .../wizard/wizard-field-select.tsx | 41 ++-- .../_components/wizard/wizard-sidebar.tsx | 15 +- .../wizard/wizard-step-location-service.tsx | 179 ++++++++++++++---- .../wizard/wizard-step-type-schedule.tsx | 60 +++++- .../wizard/wizard-step-vendor-time.tsx | 31 ++- .../_components/work-order-form.tsx | 24 ++- 9 files changed, 500 insertions(+), 150 deletions(-) diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx index 4c4132fd..4b6ad5d2 100644 --- a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx @@ -7,10 +7,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 +92,46 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) { return ; } const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`; - const selected = iso === value; const isToday = iso === today; + + let bg = "transparent"; + let fg: string = isToday ? "var(--color-primary)" : "var(--color-text-primary)"; + let radius: number | string = 4; + let filled = false; + + if (range && rangeStart) { + const lo = !rangeEnd ? rangeStart : rangeStart <= rangeEnd ? rangeStart : rangeEnd; + const hi = !rangeEnd ? rangeStart : rangeStart <= rangeEnd ? rangeEnd : rangeStart; + if (lo === hi) { + if (iso === lo) { + filled = true; + bg = "var(--color-primary)"; + fg = "var(--color-primary-foreground)"; + radius = 4; + } + } else if (iso === lo) { + filled = true; + bg = "var(--color-primary)"; + fg = "var(--color-primary-foreground)"; + radius = "4px 0 0 4px"; + } else if (iso === hi) { + filled = true; + bg = "var(--color-primary)"; + fg = "var(--color-primary-foreground)"; + radius = "0 4px 4px 0"; + } else if (iso > lo && iso < hi) { + filled = true; + bg = "var(--color-primary-tint)"; + fg = "var(--color-primary)"; + radius = 0; + } + } else if (iso === value) { + filled = true; + bg = "var(--color-primary)"; + fg = "var(--color-primary-foreground)"; + radius = 4; + } + return ( ); })} @@ -130,6 +196,52 @@ export function WizardDateField({ close(); }} /> + ) : allowRange && cellMode === "range" ? ( + <> + + {Boolean(rangeStart) && ( +
+ {(() => { + const n = rangeDayCount(rangeStart, rangeEnd || rangeStart); + return `${n} ${n === 1 ? "day" : "days"} selected`; + })()} +
+ )} + + ) : ( o.key === value); const filtered = q - ? options.filter( - (o) => - o.label.toLowerCase().includes(q.toLowerCase()) || - o.key.toLowerCase().includes(q.toLowerCase()), - ) + ? options.filter((o) => { + const needle = q.toLowerCase(); + return ( + o.label.toLowerCase().includes(needle) || + o.key.toLowerCase().includes(needle) || + (o.description?.toLowerCase().includes(needle) ?? false) + ); + }) : options; const handleClose = () => { @@ -74,9 +79,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 ? ( {o.left != null && {o.left}} - - {o.label} + + + {o.label} + + {Boolean(o.description) && ( + + {o.description} + + )} + {o.right != null && {o.right}} {value === o.key && ( )} diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx index 62a13a75..b72cc992 100644 --- a/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx +++ b/src/app/(protected)/workorders/_components/wizard/wizard-sidebar.tsx @@ -7,6 +7,7 @@ import { } from "@/domain/work-orders/types/work-order-wizard"; import { fmtDate, + fmtDateRange, fmtWeekRange, formatApptRange, } from "@/domain/work-orders/utils/wizard-date-utils"; @@ -127,15 +128,17 @@ function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) { ["Type", draft.type || "—"], [ "Schedule on", - draft.scheduledOn - ? draft.scheduleWeekOnly - ? fmtWeekRange(draft.scheduledOn) - : fmtDate(draft.scheduledOn) - : "—", + draft.schedMode === "range" && draft.scheduledOn && draft.scheduleEnd + ? fmtDateRange(draft.scheduledOn, draft.scheduleEnd) + : draft.scheduledOn + ? draft.scheduleWeekOnly + ? fmtWeekRange(draft.targetWeek || draft.scheduledOn) + : fmtDate(draft.scheduledOn) + : "—", ], ["Due date", fmtDate(draft.dueDate) || "—"], ["Assigned to", draft.assigneeName || "—"], - ["Site", draft.locationName || "—"], + ["Site", draft.siteCode || draft.locationName || "—"], [ "POC", draft.pocName 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..b63cdfff 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,9 +1,13 @@ -import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react"; +import { Droplets, ExternalLink, Info, Mail, MapPin, Phone, Plus } 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 { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper"; +import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper"; 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 { DLG_INPUT_CLS, DLG_INPUT_STYLE, @@ -20,26 +24,48 @@ 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)}`; + const query = formatLocationAddressPreview(location); + return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`; } 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); + const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : ""; + const svcSel = [draft.pm, ...draft.extraServices].filter((p, i, a) => !!p && a.indexOf(p) === i); + + const toggleSvc = (p: string) => { + const next = svcSel.includes(p) ? svcSel.filter((x) => x !== p) : [...svcSel, p]; + onPatch({ + pm: next[0] ?? "", + extraServices: next.slice(1), + }); + }; + return (
@@ -60,6 +86,7 @@ export function WizardStepLocationService({ onPatch({ locationId: id, locationName: loc?.name ?? "", + siteCode: loc ? resolveSiteCode(loc) : "", }); }} /> @@ -75,43 +102,69 @@ export function WizardStepLocationService({ )}
- {locationDetail != null && draft.locationId !== "" && ( + {draft.locationId !== "" && ( <>
- - {Boolean(locationDetail.phone) && ( - - )} - {Boolean(locationDetail.contactEmail) && ( - - )} - {Boolean(locationDetail.address) && ( - - Open in Google Maps - + {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. + + )} + )}
+
Point of contact + } searchPlaceholder="Search PM type…" - options={services.map((s) => ({ - key: s.value, - label: s.value, - }))} - onPick={(p) => onUpdate("pm", p)} + options={services.map((s) => { + const Icon = getPmTypeIcon(s.value); + return { + key: s.value, + label: s.value, + left: ( + + ), + }; + })} + onPick={(p) => + onPatch({ + pm: p, + extraServices: draft.extraServices.filter((x) => x !== p), + }) + } /> + {!svcOpen ? ( + + ) : ( +
+ onUpdate("pmNote", v)} + search={svcSearch} + setSearch={onSvcSearchChange} + /> +
+ )}
{!draft.locationId && !draft.pm && ( 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 e3d4de1e..f1a4115d 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 { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select"; import { @@ -124,6 +125,8 @@ export function WizardStepTypeSchedule({ onUpdate, onPatch, }: WizardStepTypeScheduleProps) { + const selectedUser = users.find((u) => String(u.id) === draft.assigneeId); + return (
@@ -135,9 +138,10 @@ export function WizardStepTypeSchedule({ WO Number onUpdate("woNumber", e.target.value)} - placeholder="e.g. 24818" + onChange={(e) => onUpdate("woNumber", e.target.value.replace(/\D/g, "").slice(0, 20))} + placeholder="e.g. 12345678901" maxLength={20} className={DLG_INPUT_CLS} style={DLG_INPUT_STYLE} @@ -150,13 +154,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); @@ -173,8 +181,15 @@ export function WizardStepTypeSchedule({ onPatch({ scheduledOn: "", scheduleWeekOnly: false }) + draft.scheduledOn !== "" || draft.scheduleWeekOnly || draft.scheduleEnd + ? () => + onPatch({ + scheduledOn: "", + scheduleWeekOnly: false, + scheduleEnd: "", + schedMode: undefined, + targetWeek: "", + }) : undefined } > @@ -182,11 +197,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..cb2a7cbf 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,4 +1,4 @@ -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 { @@ -9,6 +9,10 @@ import { 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, @@ -33,11 +37,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 +67,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 +151,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 +162,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 +180,7 @@ export function WizardStepVendorTime({ vendor={selectedVendor} techPhone={draft.techPhone} vendorNotes={draft.vendorNotes} + distance={selectedDistance} onUpdate={onUpdate} /> )} 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()} + /> ); }