feat(work-orders): board wizard create with sites and service notes

This commit is contained in:
Arthur Bassi 2026-07-27 13:38:53 -03:00
parent 68b85940fe
commit 27131a1533
9 changed files with 500 additions and 150 deletions

View file

@ -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 <span key={i} />;
}
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 (
<button
key={i}
@ -91,16 +140,12 @@ export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
style={{
height: 26,
borderRadius: 4,
fontWeight: selected || isToday ? 600 : 400,
background: selected ? "var(--color-primary)" : "transparent",
color: selected
? "var(--color-primary-foreground)"
: isToday
? "var(--color-primary)"
: "var(--color-text-primary)",
borderRadius: radius,
fontWeight: filled || isToday ? 600 : 400,
background: filled ? bg : undefined,
color: fg,
border:
isToday && !selected
isToday && !filled
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
: undefined,
}}

View file

@ -1,32 +1,37 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { Text } from "@/components/ui/text";
import { guardDialogClose } from "@/lib/dialog-guard";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
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 { 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 { 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 = {
@ -76,47 +81,36 @@ function WizardStepHeader({ step }: { step: number }) {
}
export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
const mockFallback = isWoWizardMockEnabled();
const [step, setStep] = useState(1);
const [draft, setDraft] = useState<WorkOrderWizardDraft>({
...EMPTY_WIZARD_DRAFT,
status: "Incomplete",
});
const [pocAutoFilledFor, setPocAutoFilledFor] = useState("");
const [svcOpen, setSvcOpen] = useState(false);
const [svcSearch, setSvcSearch] = 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 { data: locations = [] } = useLocationSites();
const { data: problems = [] } = useDropdownOptionsByCategory("Problem");
const {
data: locationDetail,
isLoading: locationDetailLoading,
isError: locationDetailError,
} = useLocationDetail(draft.locationId || undefined);
const { data: vendors = [] } = useWorkOrderVendors(
draft.pm.trim() || 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("");
setSvcOpen(false);
setSvcSearch("");
}
}, [open]);
@ -151,10 +145,13 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
const handleCreate = () => createMutation.mutate(draft);
const isCreating = createMutation.isPending;
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -166,13 +163,15 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
maxWidth: 768,
height: 680,
maxHeight: 680,
pointerEvents: isCreating ? "none" : undefined,
opacity: isCreating ? 0.85 : 1,
},
},
}}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1">
<div className="flex min-h-0 flex-1" aria-busy={isCreating || undefined}>
<WizardSidebar step={step} draft={draft} />
<div
@ -184,7 +183,7 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
{step === 1 && (
<WizardStepTypeSchedule
draft={draft}
users={wizardUsers}
users={users}
onUpdate={setField}
onPatch={patchDraft}
/>
@ -195,7 +194,13 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
draft={draft}
locations={locations}
locationDetail={locationDetail}
locationDetailLoading={locationDetailLoading}
locationDetailError={locationDetailError}
services={problems.map((p) => ({ id: p.id, value: p.value }))}
svcOpen={svcOpen}
onSvcOpenChange={setSvcOpen}
svcSearch={svcSearch}
onSvcSearchChange={setSvcSearch}
onUpdate={setField}
onPatch={patchDraft}
/>
@ -215,11 +220,13 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
<WizardFooter
step={step}
draft={draft}
onCancel={() => onOpenChange(false)}
onCancel={() => {
if (!isCreating) onOpenChange(false);
}}
onBack={() => setStep((s) => s - 1)}
onContinue={() => setStep((s) => s + 1)}
onCreate={handleCreate}
isCreating={createMutation.isPending}
isCreating={isCreating}
/>
</Dialog>
);

View file

@ -1,9 +1,15 @@
import { useState } from "react";
import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react";
import { useEffect, useState } from "react";
import { CalendarDays, CalendarIcon, CalendarRange, 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 type { WizardSchedMode } from "@/domain/work-orders/types/work-order-wizard";
import {
fmtDate,
fmtDateRange,
fmtWeekRange,
rangeDayCount,
} from "@/domain/work-orders/utils/wizard-date-utils";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
@ -14,6 +20,9 @@ type WizardDateFieldProps = {
allowWeekMode?: boolean;
targetWeek?: string;
onChangeWeek?: (weekStart: string) => void;
scheduleEnd?: string;
schedMode?: WizardSchedMode;
onChangeRange?: (start: string, end: string) => void;
};
export function WizardDateField({
@ -23,12 +32,57 @@ 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 initialMode: WizardSchedMode =
allowRange && schedMode === "range"
? "range"
: allowWeekMode && !value && targetWeek
? "week"
: "date";
const [cellMode, setCellMode] = useState<WizardSchedMode>(initialMode);
const [rangeStart, setRangeStart] = useState(value || "");
const [rangeEnd, setRangeEnd] = useState(scheduleEnd || "");
useEffect(() => {
if (open) {
setRangeStart(value || "");
setRangeEnd(scheduleEnd || "");
setCellMode(initialMode);
}
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
const showRange = Boolean(allowRange && schedMode === "range" && value && scheduleEnd);
function handleRangeClick(iso: string) {
if (!rangeStart || (rangeStart && rangeEnd)) {
setRangeStart(iso);
setRangeEnd("");
return;
}
if (iso < rangeStart) {
setRangeStart(iso);
setRangeEnd(rangeStart);
} else {
setRangeEnd(iso);
}
}
const modes: WizardSchedMode[] = allowRange
? ["week", "date", "range"]
: allowWeekMode
? ["week", "date"]
: [];
const modeLabel = (m: WizardSchedMode) =>
m === "week" ? "Week" : m === "range" ? "Range" : allowRange ? "Date" : "Specific date";
return (
<>
@ -52,7 +106,13 @@ export function WizardDateField({
}}
aria-haspopup="dialog"
>
{showWeek ? (
{showRange ? (
<CalendarRange
size={16}
className="shrink-0"
style={{ color: "var(--muted-foreground)" }}
/>
) : showWeek ? (
<CalendarDays
size={16}
className="shrink-0"
@ -70,10 +130,16 @@ export function WizardDateField({
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value || showWeek ? "var(--foreground)" : "var(--muted-foreground)",
color: value || showWeek || showRange ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder}
{showRange
? fmtDateRange(value, scheduleEnd)
: showWeek
? fmtWeekRange(targetWeek)
: value
? fmtDate(value)
: placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
@ -91,9 +157,9 @@ export function WizardDateField({
},
}}
>
{Boolean(allowWeekMode) && (
{modes.length > 0 && (
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
{(["week", "date"] as const).map((m) => {
{modes.map((m) => {
const activeMode = cellMode === m;
return (
<button
@ -114,7 +180,7 @@ export function WizardDateField({
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
{modeLabel(m)}
</button>
);
})}
@ -130,6 +196,52 @@ export function WizardDateField({
close();
}}
/>
) : allowRange && cellMode === "range" ? (
<>
<MiniCalendar
range
value={rangeStart}
rangeStart={rangeStart}
rangeEnd={rangeEnd}
onChange={handleRangeClick}
/>
{Boolean(rangeStart) && (
<div
className="text-center"
style={{ marginTop: 4, fontSize: 11.5, color: "var(--color-text-muted)" }}
>
{(() => {
const n = rangeDayCount(rangeStart, rangeEnd || rangeStart);
return `${n} ${n === 1 ? "day" : "days"} selected`;
})()}
</div>
)}
<button
type="button"
disabled={!rangeStart || !rangeEnd}
onClick={() => {
if (rangeStart && rangeEnd) {
onChangeRange?.(rangeStart, rangeEnd);
close();
}
}}
className="w-full transition-opacity"
style={{
marginTop: 4,
height: 32,
borderRadius: 6,
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
fontFamily: "var(--font-sans)",
fontSize: 12.5,
fontWeight: 500,
opacity: !rangeStart || !rangeEnd ? 0.5 : 1,
pointerEvents: !rangeStart || !rangeEnd ? "none" : "auto",
}}
>
Done
</button>
</>
) : (
<MiniCalendar
value={value}

View file

@ -8,7 +8,9 @@ import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption {
key: string;
label: string;
description?: string;
left?: ReactNode;
right?: ReactNode;
}
type WizardFieldSelectProps = {
@ -36,11 +38,14 @@ export function WizardFieldSelect({
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.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 && <span className="flex shrink-0 items-center">{icon}</span>}
{icon == null && selected?.left != null && (
<span className="flex shrink-0 items-center">{selected.left}</span>
{(selected?.left ?? icon) != null && (
<span className="flex shrink-0 items-center">{selected?.left ?? icon}</span>
)}
{selected ? (
<span
@ -150,12 +154,23 @@ export function WizardFieldSelect({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
<span
className="flex-1 truncate text-left text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
<span className="min-w-0 flex-1 text-left">
<span
className="block truncate text-[12px]"
style={{ color: "var(--color-text-primary)" }}
>
{o.label}
</span>
{Boolean(o.description) && (
<span
className="mt-0.5 block truncate text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
{o.description}
</span>
)}
</span>
{o.right != null && <span className="flex shrink-0 items-center">{o.right}</span>}
{value === o.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}

View file

@ -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

View file

@ -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: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => 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 (
<div className="space-y-5">
<div>
@ -60,6 +86,7 @@ export function WizardStepLocationService({
onPatch({
locationId: id,
locationName: loc?.name ?? "",
siteCode: loc ? resolveSiteCode(loc) : "",
});
}}
/>
@ -75,43 +102,69 @@ export function WizardStepLocationService({
)}
</div>
{locationDetail != null && draft.locationId !== "" && (
{draft.locationId !== "" && (
<>
<div
className="space-y-1.5 rounded-lg p-3"
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
>
<Detail
icon={MapPin}
text={
[
locationDetail.address,
locationDetail.city,
locationDetail.state,
locationDetail.zipCode,
]
.filter(Boolean)
.join(", ") || "—"
}
/>
{Boolean(locationDetail.phone) && (
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
{Boolean(locationDetail.address) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
{locationDetail ? (
<>
<Detail
icon={MapPin}
text={addressPreview || selectedLoc?.name || draft.locationName || "—"}
/>
{Boolean(locationDetail.phone) && (
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
)}
{Boolean(locationDetail.contactEmail) && (
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
style={{ color: "var(--color-primary)" }}
>
<ExternalLink size={12} /> Open in Google Maps
</a>
)}
</>
) : (
<>
<Detail icon={MapPin} text={selectedLoc?.name || draft.locationName || "—"} />
{Boolean(locationDetailLoading) && (
<div
className="space-y-1.5"
aria-busy="true"
role="status"
aria-label="Loading site details"
>
<span
className="block h-3 w-3/4 animate-pulse rounded"
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
/>
<span
className="block h-3 w-1/2 animate-pulse rounded"
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
/>
</div>
)}
{Boolean(locationDetailError) && (
<Text
as="p"
variant="description"
sx={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
Could not load full site details. City and site code above are still available.
</Text>
)}
</>
)}
</div>
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<Text
@ -155,15 +208,59 @@ export function WizardStepLocationService({
value={draft.pm}
placeholder="Select service…"
icon={
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
<Droplets size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
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: (
<Icon size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
),
};
})}
onPick={(p) =>
onPatch({
pm: p,
extraServices: draft.extraServices.filter((x) => x !== p),
})
}
/>
{!svcOpen ? (
<button
type="button"
onClick={() => onSvcOpenChange(true)}
className="transition-colors"
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--color-primary)",
padding: "6px 0 0",
display: "flex",
alignItems: "center",
gap: 4,
}}
>
<Plus size={12} /> Service &amp; Notes
</button>
) : (
<div
className="mt-2 rounded-lg p-3"
style={{ background: "var(--color-bg-muted)", border: "1px solid var(--color-border)" }}
>
<WizardServiceNotesFields
services={services}
sel={svcSel}
onToggle={toggleSvc}
note={draft.pmNote}
setNote={(v) => onUpdate("pmNote", v)}
search={svcSearch}
setSearch={onSvcSearchChange}
/>
</div>
)}
</div>
{!draft.locationId && !draft.pm && (

View file

@ -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 (
<div className="space-y-6">
<div>
@ -135,9 +138,10 @@ export function WizardStepTypeSchedule({
<WizLabel optional>WO Number</WizLabel>
<input
type="text"
inputMode="numeric"
value={draft.woNumber}
onChange={(e) => 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={
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
selectedUser ? (
<ColorDot color={getDispatcherAvatarColor(selectedUser)} />
) : (
<Users size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search dispatcher…"
options={users.map((u) => ({
key: String(u.id),
label: u.name,
left: <ColorDot color="var(--color-primary)" />,
left: <ColorDot color={getDispatcherAvatarColor(u)} />,
}))}
onPick={(id) => {
const user = users.find((u) => String(u.id) === id);
@ -173,8 +181,15 @@ export function WizardStepTypeSchedule({
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly
? () => 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({
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
targetWeek={draft.scheduleWeekOnly ? draft.targetWeek || draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
onChange={(iso) => 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: "",
})
}
/>
</div>

View file

@ -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: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
}) {
return (
@ -61,9 +67,20 @@ function WizardVendorDetail({
border: "1px solid var(--color-border)",
}}
>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "—"}
<div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{vendor.companyName || "—"}
</div>
{Boolean(distance) && (
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
{distance}
</span>
)}
</div>
{Boolean(vendor.contactName) && <Detail icon={User} text={vendor.contactName} />}
{Boolean(vendor.tradeSpecialties) && (
<Detail icon={Wrench} text={vendor.tradeSpecialties} />
)}
{Boolean(vendor.address) && <Detail icon={Mail} text={vendor.address} />}
{Boolean(vendor.address) && (
<a
@ -102,6 +119,7 @@ export function WizardStepVendorTime({
const selectedVendor = vendors.find((v) => 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 (
<div className="space-y-5">
@ -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()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
@ -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}
/>
)}

View file

@ -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 (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
return <DetailPageSkeleton variant="form" />;
}
if (detailError) {
return (
<Box className="p-4">
<Text variant="error">Unable to load this work order. Please try again.</Text>
<QueryErrorPanel
message={detailError instanceof Error ? detailError.message : "Failed to load work order"}
onRetry={() => void refetchDetail()}
/>
</Box>
);
}