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

This commit is contained in:
Arthur Bassi 2026-07-17 10:48:17 -03:00
parent 46b8184d62
commit bf61508fe4
10 changed files with 844 additions and 361 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,36 @@
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 { guardDialogClose } from "@/lib/dialog-guard";
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 { 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 = {
@ -36,47 +40,36 @@ type NewWoWizardProps = {
};
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]);
@ -110,13 +103,17 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
};
const handleCreate = () => {
createMutation.mutate(mapWizardDraftToFormValues(draft));
createMutation.mutate(draft);
};
const isCreating = createMutation.isPending;
const handleClose = guardDialogClose(isCreating, () => onOpenChange(false));
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
onClose={handleClose}
disableEscapeKeyDown={isCreating}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
@ -128,13 +125,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
@ -180,7 +179,7 @@ export function NewWoWizard({ open, onOpenChange, users }: NewWoWizardProps) {
{step === 1 && (
<WizardStepTypeSchedule
draft={draft}
users={wizardUsers}
users={users}
onUpdate={setField}
onPatch={patchDraft}
/>
@ -191,7 +190,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}
/>
@ -211,11 +216,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({
},
}}
>
{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}
/>
{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

@ -7,7 +7,9 @@ import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption {
key: string;
label: string;
description?: string;
left?: ReactNode;
right?: ReactNode;
}
type WizardFieldSelectProps = {
@ -35,11 +37,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 = () => {
@ -73,9 +78,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
@ -148,12 +152,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>
{o.description ? (
<span
className="mt-0.5 block truncate text-[11px]"
style={{ color: "var(--color-text-muted)" }}
>
{o.description}
</span>
) : null}
</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

@ -0,0 +1,140 @@
import type { ReactNode } from "react";
import { Check } from "lucide-react";
import { getPmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
type WizardServiceNotesFieldsProps = {
services: { id: string | number; value: string }[];
sel: string[];
onToggle: (service: string) => void;
note: string;
setNote: (v: string) => void;
search: string;
setSearch: (v: string) => void;
};
function SvcSectionLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-text-muted)",
marginBottom: 6,
}}
>
{children}
</div>
);
}
/** Multi-select services + notes — mirrors design ServiceNotesFields (wizard inline). */
export function WizardServiceNotesFields({
services,
sel,
onToggle,
note,
setNote,
search,
setSearch,
}: WizardServiceNotesFieldsProps) {
const filtered = search
? services.filter((s) => s.value.toLowerCase().includes(search.toLowerCase()))
: services;
return (
<>
<div>
<SvcSectionLabel>Services</SvcSectionLabel>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search services…"
className="mb-1.5 w-full rounded px-2 py-1.5 text-[12px] focus:outline-none focus:ring-1"
style={{
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
/>
<div
className="rounded"
style={{ maxHeight: 200, overflowY: "auto", border: "1px solid var(--color-border)" }}
>
{filtered.map((s) => {
const checked = sel.includes(s.value);
const isPrimary = checked && sel[0] === s.value;
const Icon = getPmTypeIcon(s.value);
return (
<button
key={String(s.id)}
type="button"
onClick={() => onToggle(s.value)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-[#F9FAFB]"
>
<span
className="flex shrink-0 items-center justify-center"
style={{
width: 16,
height: 16,
borderRadius: 4,
border: `1px solid ${checked ? "var(--color-primary)" : "var(--color-border)"}`,
background: checked ? "var(--color-primary)" : "var(--color-bg-surface)",
}}
>
{checked && <Check size={11} style={{ color: "#fff" }} />}
</span>
<Icon size={13} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span
className="min-w-0 flex-1 truncate text-[13px]"
style={{ color: "var(--color-text-primary)" }}
>
{s.value}
</span>
{isPrimary && (
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--color-primary)",
background: "var(--color-primary-tint)",
padding: "1px 6px",
borderRadius: 4,
}}
>
Primary
</span>
)}
</button>
);
})}
{filtered.length === 0 && (
<div className="px-2 py-3 text-[12px]" style={{ color: "var(--color-text-muted)" }}>
No services match.
</div>
)}
</div>
</div>
<div style={{ marginTop: 16 }}>
<SvcSectionLabel>Notes / Issue Detail</SvcSectionLabel>
<textarea
rows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Describe the specific issue, location details, door number, etc."
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontSize: 13, fontFamily: "inherit" }}
/>
</div>
</>
);
}

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";
@ -26,15 +27,17 @@ export function WizardSidebar({ step, draft }: WizardSidebarProps) {
["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,8 +1,12 @@
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
import { Droplets, ExternalLink, Info, Mail, MapPin, Phone, Plus } from "lucide-react";
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,
@ -19,26 +23,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>
@ -59,6 +85,7 @@ export function WizardStepLocationService({
onPatch({
locationId: id,
locationName: loc?.name ?? "",
siteCode: loc ? resolveSiteCode(loc) : "",
});
}}
/>
@ -72,41 +99,63 @@ export function WizardStepLocationService({
)}
</div>
{locationDetail && 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(", ") || "—"
}
/>
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
{locationDetail.contactEmail && (
<Detail icon={Mail} text={locationDetail.contactEmail} />
)}
{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 || "—"}
/>
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
{locationDetail.contactEmail && (
<Detail icon={Mail} text={locationDetail.contactEmail} />
)}
{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 || "—"} />
{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>
)}
{locationDetailError && (
<p style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
Could not load full site details. City and site code above are still available.
</p>
)}
</>
)}
</div>
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<p style={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}>
@ -146,22 +195,60 @@ 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 && (
<p style={{ fontSize: 12, fontStyle: "italic", color: "var(--muted-foreground)" }}>
Both are optional — leave blank to fill in later from the dispatch board.
</p>
)}
</div>
);
}

View file

@ -1,6 +1,7 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
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 {
@ -36,6 +37,8 @@ export function WizardStepTypeSchedule({
onUpdate,
onPatch,
}: WizardStepTypeScheduleProps) {
const selectedUser = users.find((u) => String(u.id) === draft.assigneeId);
return (
<div className="space-y-6">
<div>
@ -122,9 +125,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}
@ -137,13 +141,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);
@ -160,8 +168,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
}
>
@ -169,11 +184,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 type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
ALL_WIZARD_STATUSES,
@ -8,6 +8,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,
@ -37,6 +41,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">
@ -68,7 +73,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)" }} />
@ -77,7 +84,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);
@ -111,9 +118,25 @@ export function WizardStepVendorTime({
border: "1px solid var(--color-border)",
}}
>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{selectedVendor.companyName || "—"}
<div className="flex items-start justify-between gap-2">
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{selectedVendor.companyName || "—"}
</div>
{selectedDistance ? (
<span
className="shrink-0 text-[11px]"
style={{ color: "var(--muted-foreground)" }}
>
{selectedDistance}
</span>
) : null}
</div>
{selectedVendor.contactName && (
<Detail icon={User} text={selectedVendor.contactName} />
)}
{selectedVendor.tradeSpecialties && (
<Detail icon={Wrench} text={selectedVendor.tradeSpecialties} />
)}
{selectedVendor.address && <Detail icon={Mail} text={selectedVendor.address} />}
{selectedVendor.address && (
<a

View file

@ -3,12 +3,9 @@ import { Controller, useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Link as RouterLink, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Autocomplete,
Box,
Breadcrumbs,
Button,
CircularProgress,
Link,
MenuItem,
Paper,
@ -16,6 +13,9 @@ import {
TextField,
Typography,
} from "@mui/material";
import { Button } from "@/components/ui/button";
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
import { DetailPageSkeleton } from "@/app/(protected)/workorders/_components/loading/detail-page-skeleton";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWorkOrderDetailToFormValues } from "@/domain/work-orders/mappers/work-order-mapper";
import {
@ -51,7 +51,12 @@ export default function WorkOrderFormPage() {
const navigate = useNavigate();
const isEdit = Boolean(id);
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");
@ -108,19 +113,16 @@ export default function WorkOrderFormPage() {
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
return <DetailPageSkeleton variant="form" />;
}
if (detailError) {
return (
<Box className="p-4">
<Alert severity="error">
{detailError instanceof Error ? detailError.message : "Failed to load work order"}
</Alert>
<QueryErrorPanel
message={detailError instanceof Error ? detailError.message : "Failed to load work order"}
onRetry={() => void refetchDetail()}
/>
</Box>
);
}
@ -139,215 +141,222 @@ export default function WorkOrderFormPage() {
</Typography>
<Paper variant="outlined" className="p-6">
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-8">
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("workOrderNumber")}
label="Customer WO"
placeholder="Customer work order number"
fullWidth
/>
<TextField
{...register("title")}
label="Title"
required
error={Boolean(errors.title)}
helperText={errors.title?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
<Controller
name="locationId"
control={control}
render={({ field }) => (
<Autocomplete
options={locationOptions}
loading={isLoadingLocations}
getOptionLabel={(option: LocationOption) => option.name}
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
renderInput={(params) => (
<TextField
{...params}
label="Location"
required
error={Boolean(errors.locationId)}
helperText={errors.locationId?.message}
/>
)}
className="flex-1"
/>
)}
/>
<TextField
{...register("source")}
label="Source"
placeholder="e.g., Amazon APM, Manual"
fullWidth
/>
</Stack>
</Box>
<Box
component="form"
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-8"
aria-busy={isSaving || undefined}
>
<fieldset disabled={isSaving} className="m-0 flex flex-col gap-8 border-0 p-0">
<Box>
<SectionTitle>Work Order Details</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("workOrderNumber")}
label="Customer WO"
placeholder="Customer work order number"
fullWidth
/>
<TextField
{...register("title")}
label="Title"
required
error={Boolean(errors.title)}
helperText={errors.title?.message}
fullWidth
/>
</Stack>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} className="mt-4">
<Controller
name="locationId"
control={control}
render={({ field }) => (
<Autocomplete
options={locationOptions}
loading={isLoadingLocations}
getOptionLabel={(option: LocationOption) => option.name}
value={locationOptions.find((loc) => String(loc.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
renderInput={(params) => (
<TextField
{...params}
label="Location"
required
error={Boolean(errors.locationId)}
helperText={errors.locationId?.message}
/>
)}
className="flex-1"
/>
)}
/>
<TextField
{...register("source")}
label="Source"
placeholder="e.g., Amazon APM, Manual"
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("problem")} label="Problem" select fullWidth>
<MenuItem value="">Select Problem</MenuItem>
{problems.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
<Box>
<SectionTitle>Classification</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField {...register("problem")} label="Problem" select fullWidth>
<MenuItem value="">Select Problem</MenuItem>
{problems.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField {...register("trade")} label="Trade" select fullWidth>
<MenuItem value="">Select Trade</MenuItem>
{trades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField
{...register("subTrade")}
label="Sub-Trade"
select
disabled={!tradeValue}
fullWidth
>
<MenuItem value="">
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
</MenuItem>
))}
</TextField>
<TextField {...register("trade")} label="Trade" select fullWidth>
<MenuItem value="">Select Trade</MenuItem>
{trades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
<TextField
{...register("subTrade")}
label="Sub-Trade"
select
disabled={!tradeValue}
fullWidth
>
<MenuItem value="">
{tradeValue ? "Select Sub-Trade" : "Select a Trade first"}
</MenuItem>
{subTrades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
</Stack>
</Box>
{subTrades.map((option) => (
<MenuItem key={String(option.id)} value={option.value}>
{option.value}
</MenuItem>
))}
</TextField>
</Stack>
</Box>
<Box>
<SectionTitle>Schedule & Priority</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("priority")}
label="Priority"
select
required
error={Boolean(errors.priority)}
helperText={errors.priority?.message}
fullWidth
>
<MenuItem value="">Select Priority</MenuItem>
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
<MenuItem key={priority} value={priority}>
{priority}
</MenuItem>
))}
</TextField>
<TextField {...register("status")} label="Status" select required fullWidth>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
<Controller
name="assignedTo"
control={control}
render={({ field }) => (
<Autocomplete
freeSolo
options={employees.map((employee) => employee.name)}
value={field.value}
onChange={(_event, value) => field.onChange(value ?? "")}
onInputChange={(_event, value) => field.onChange(value)}
renderInput={(params) => (
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
)}
className="flex-1"
/>
)}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
<TextField
{...register("createdDate")}
label="Created"
type="date"
required
error={Boolean(errors.createdDate)}
helperText={errors.createdDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("dueDate")}
label="Due Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("scheduledDate")}
label="Scheduled"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("completedDate")}
label="Completed"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Schedule & Priority</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("priority")}
label="Priority"
select
required
error={Boolean(errors.priority)}
helperText={errors.priority?.message}
fullWidth
>
<MenuItem value="">Select Priority</MenuItem>
{WORK_ORDER_PRIORITY_OPTIONS.map((priority) => (
<MenuItem key={priority} value={priority}>
{priority}
</MenuItem>
))}
</TextField>
<TextField {...register("status")} label="Status" select required fullWidth>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</TextField>
<Controller
name="assignedTo"
control={control}
render={({ field }) => (
<Autocomplete
freeSolo
options={employees.map((employee) => employee.name)}
value={field.value}
onChange={(_event, value) => field.onChange(value ?? "")}
onInputChange={(_event, value) => field.onChange(value)}
renderInput={(params) => (
<TextField {...params} label="Assigned To" placeholder="Assigned to" />
)}
className="flex-1"
/>
)}
/>
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mt-4">
<TextField
{...register("createdDate")}
label="Created"
type="date"
required
error={Boolean(errors.createdDate)}
helperText={errors.createdDate?.message}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("dueDate")}
label="Due Date"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("scheduledDate")}
label="Scheduled"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<TextField
{...register("completedDate")}
label="Completed"
type="date"
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Financial</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<Box>
<SectionTitle>Financial</SectionTitle>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
inputProps={{ step: "0.01", min: 0 }}
fullWidth
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
{...register("tt")}
label="TT"
placeholder="Trouble ticket number"
fullWidth
/>
</Stack>
</Box>
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("vendorNTE")}
label="Vendor NTE"
type="number"
inputProps={{ step: "0.01", min: 0 }}
fullWidth
/>
<TextField {...register("po")} label="Customer PO" fullWidth />
<TextField
{...register("tt")}
label="TT"
placeholder="Trouble ticket number"
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" loading={isSaving} loadingText="Saving…">
{isEdit ? "Update" : "Create"}
</Button>
</Stack>
</Box>
<Box>
<SectionTitle>Description</SectionTitle>
<TextField
{...register("description")}
label="Description"
multiline
rows={8}
placeholder="Detailed description of the work to be performed"
fullWidth
/>
</Box>
<Stack direction="row" spacing={2} justifyContent="flex-end">
<Button variant="outlined" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update" : "Create"}
</Button>
</Stack>
</fieldset>
</Box>
</Paper>
</Box>