mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
feat(work-orders): board wizard create with sites and service notes
This commit is contained in:
parent
46b8184d62
commit
bf61508fe4
10 changed files with 844 additions and 361 deletions
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)" }} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue