feat(work-orders): add board wizard shell UI

This commit is contained in:
Arthur Bassi 2026-07-17 12:08:20 -03:00
parent 7c7ac6068e
commit 84177475e7
12 changed files with 1846 additions and 0 deletions

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
type MiniCalendarProps = {
value: string;
onChange: (iso: string) => void;
};
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im);
const firstWeekday = new Date(viewY, viewM - 1, 1).getDay();
const daysInMonth = new Date(viewY, viewM, 0).getDate();
const cells: (number | null)[] = [
...Array(firstWeekday).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const step = (delta: number) => {
let m = viewM + delta;
let y = viewY;
if (m < 1) {
m = 12;
y -= 1;
}
if (m > 12) {
m = 1;
y += 1;
}
setViewM(m);
setViewY(y);
};
const today = todayIso();
return (
<div style={{ width: 224 }}>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => step(-1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Previous month"
>
<ChevronLeft size={14} />
</button>
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
{MONTH_NAMES[viewM - 1]} {viewY}
</span>
<button
type="button"
onClick={() => step(1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Next month"
>
<ChevronRight size={14} />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-0.5">
{WEEKDAY_HEADER.map((d, i) => (
<span
key={i}
className="py-0.5 text-center text-[10px] font-medium"
style={{ color: "var(--color-text-muted)" }}
>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{cells.map((day, i) => {
if (day === null) {
return <span key={i} />;
}
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
const selected = iso === value;
const isToday = iso === today;
return (
<button
key={i}
type="button"
onClick={() => onChange(iso)}
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)",
border:
isToday && !selected
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
: undefined,
}}
>
{day}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from "react";
import { Dialog } from "@mui/material";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import { isWoWizardMockEnabled } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
import {
getMockWoWizardLocationDetail,
MOCK_WO_WIZARD_LOCATIONS,
MOCK_WO_WIZARD_SERVICES,
MOCK_WO_WIZARD_USERS,
MOCK_WO_WIZARD_VENDORS,
} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
EMPTY_WIZARD_DRAFT,
WIZARD_STEPS,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import {
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
import { WizardFooter } from "@/app/(protected)/workorders/_components/wizard/wizard-footer";
import { WizardSidebar } from "@/app/(protected)/workorders/_components/wizard/wizard-sidebar";
import { WizardStepLocationService } from "@/app/(protected)/workorders/_components/wizard/wizard-step-location-service";
import { WizardStepTypeSchedule } from "@/app/(protected)/workorders/_components/wizard/wizard-step-type-schedule";
import { WizardStepVendorTime } from "@/app/(protected)/workorders/_components/wizard/wizard-step-vendor-time";
type NewWoWizardProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
users: WorkOrderUser[];
};
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 { data: apiLocations = [] } = useWorkOrderLocations();
const { data: apiProblems = [] } = useDropdownOptionsByCategory("Problem");
const isMockLocation = draft.locationId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation ? undefined : draft.locationId || undefined,
);
const mockLocationDetail = isMockLocation
? getMockWoWizardLocationDetail(draft.locationId)
: undefined;
const locationDetail = mockLocationDetail ?? apiLocationDetail;
const { data: apiVendors = [] } = useWorkOrderVendors(
undefined,
isMockLocation ? undefined : locationDetail?.zipCode,
);
const createMutation = useCreateWorkOrderFromWizard(() => onOpenChange(false));
const wizardUsers = useMemo(() => {
if (users.length > 0) {
return users;
}
return mockFallback ? MOCK_WO_WIZARD_USERS : [];
}, [mockFallback, users]);
const locations =
apiLocations.length > 0 ? apiLocations : mockFallback ? MOCK_WO_WIZARD_LOCATIONS : [];
const problems =
apiProblems.length > 0 ? apiProblems : mockFallback ? MOCK_WO_WIZARD_SERVICES : [];
const vendors = apiVendors.length > 0 ? apiVendors : mockFallback ? MOCK_WO_WIZARD_VENDORS : [];
useEffect(() => {
if (open) {
setStep(1);
setDraft({ ...EMPTY_WIZARD_DRAFT, status: "Incomplete" });
setPocAutoFilledFor("");
}
}, [open]);
useEffect(() => {
if (!draft.locationId || !locationDetail) {
return;
}
if (pocAutoFilledFor === draft.locationId) {
return;
}
setDraft((current) => ({
...current,
pocName: locationDetail.contact ?? current.pocName,
pocPhone: locationDetail.phone ?? current.pocPhone,
}));
setPocAutoFilledFor(draft.locationId);
}, [draft.locationId, locationDetail, pocAutoFilledFor]);
const setField = <K extends keyof WorkOrderWizardDraft>(
key: K,
value: WorkOrderWizardDraft[K],
) => {
setDraft((current) => ({ ...current, [key]: value }));
};
const patchDraft = (patch: Partial<WorkOrderWizardDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
if (patch.locationId !== undefined && patch.locationId !== pocAutoFilledFor) {
setPocAutoFilledFor("");
}
};
const handleCreate = () => {
createMutation.mutate(mapWizardDraftToFormValues(draft));
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "flex flex-col overflow-hidden p-0",
style: {
borderRadius: 12,
width: 768,
maxWidth: 768,
height: 680,
maxHeight: 680,
},
},
}}
>
<span className="sr-only">Guided setup for creating a new work order</span>
<div className="flex min-h-0 flex-1">
<WizardSidebar step={step} draft={draft} />
<div
className="min-h-0 flex-1 overflow-y-auto"
style={{ padding: 32, background: "var(--color-bg-surface)" }}
>
<div style={{ marginBottom: 24 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--color-primary)",
}}
>
Step {step} of {WIZARD_STEPS.length}
</div>
<h2
style={{
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: 20,
color: "var(--foreground)",
marginTop: 6,
}}
>
{WIZARD_STEPS[step - 1].title}
</h2>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: "var(--muted-foreground)",
marginTop: 4,
}}
>
{WIZARD_STEPS[step - 1].subtitle}
</p>
</div>
{step === 1 && (
<WizardStepTypeSchedule
draft={draft}
users={wizardUsers}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 2 && (
<WizardStepLocationService
draft={draft}
locations={locations}
locationDetail={locationDetail}
services={problems.map((p) => ({ id: p.id, value: p.value }))}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
{step === 3 && (
<WizardStepVendorTime
draft={draft}
vendors={vendors}
onUpdate={setField}
onPatch={patchDraft}
/>
)}
</div>
</div>
<WizardFooter
step={step}
draft={draft}
onCancel={() => onOpenChange(false)}
onBack={() => setStep((s) => s - 1)}
onContinue={() => setStep((s) => s + 1)}
onCreate={handleCreate}
isCreating={createMutation.isPending}
/>
</Dialog>
);
}

View file

@ -0,0 +1,108 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
MONTH_NAMES,
mondayOf,
todayIso,
weekRangeLabel,
weeksOfMonth,
} from "@/domain/work-orders/utils/wizard-date-utils";
type WeekPickerProps = {
selected: string;
onPick: (mondayIso: string) => void;
};
export function WeekPicker({ selected, onPick }: WeekPickerProps) {
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
const [iy, im] = init.split("-").map(Number);
const [viewY, setViewY] = useState(iy);
const [viewM, setViewM] = useState(im);
const step = (delta: number) => {
let m = viewM + delta;
let y = viewY;
if (m < 1) {
m = 12;
y -= 1;
}
if (m > 12) {
m = 1;
y += 1;
}
setViewM(m);
setViewY(y);
};
const weeks = weeksOfMonth(viewY, viewM);
const thisMonday = mondayOf(todayIso());
return (
<div style={{ width: 240 }}>
<div className="mb-2 flex items-center justify-between">
<button
type="button"
onClick={() => step(-1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Previous month"
>
<ChevronLeft size={14} />
</button>
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
{MONTH_NAMES[viewM - 1]} {viewY}
</span>
<button
type="button"
onClick={() => step(1)}
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
style={{ color: "var(--color-text-secondary)" }}
aria-label="Next month"
>
<ChevronRight size={14} />
</button>
</div>
<div style={{ maxHeight: 200, overflowY: "auto" }} className="flex flex-col gap-1.5">
{weeks.map((mon) => {
const isSel = Boolean(selected) && selected === mon;
const isThis = mon === thisMonday;
return (
<button
key={mon}
type="button"
onClick={() => onPick(mon)}
className="flex items-center transition-colors"
style={{
height: 36,
borderRadius: 6,
fontSize: 13,
fontWeight: isSel ? 600 : 400,
padding: "0 12px",
background: isSel ? "var(--color-primary-tint)" : "transparent",
border: `1px solid ${isSel ? "var(--color-primary)" : "var(--color-border)"}`,
color: isSel ? "var(--color-primary)" : "var(--foreground)",
}}
>
{weekRangeLabel(mon)}
{isThis && (
<span
style={{
marginLeft: "auto",
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: isSel ? "var(--color-primary)" : "var(--muted-foreground)",
}}
>
This week
</span>
)}
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useState } from "react";
import { CalendarDays, CalendarIcon, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
import { fmtDate, fmtWeekRange } from "@/domain/work-orders/utils/wizard-date-utils";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type WizardDateFieldProps = {
value: string;
placeholder: string;
onChange: (iso: string) => void;
allowWeekMode?: boolean;
targetWeek?: string;
onChangeWeek?: (weekStart: string) => void;
};
export function WizardDateField({
value,
placeholder,
onChange,
allowWeekMode,
targetWeek = "",
onChangeWeek,
}: WizardDateFieldProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode && !value && targetWeek ? "week" : "date",
);
const showWeek = Boolean(allowWeekMode && !value && targetWeek);
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
outline: "none",
}}
onFocus={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
}}
aria-haspopup="dialog"
>
{showWeek ? (
<CalendarDays
size={16}
className="shrink-0"
style={{ color: "var(--muted-foreground)" }}
/>
) : (
<CalendarIcon
size={16}
className="shrink-0"
style={{ color: "var(--muted-foreground)" }}
/>
)}
<span
className="flex-1 truncate text-left"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
color: value || showWeek ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{showWeek ? fmtWeekRange(targetWeek) : value ? fmtDate(value) : placeholder}
</span>
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 rounded-lg border border-border p-3 shadow-md",
onClick: (e: React.MouseEvent) => e.stopPropagation(),
},
}}
>
{allowWeekMode && (
<div className="flex items-center gap-1.5" style={{ marginBottom: 10 }}>
{(["week", "date"] as const).map((m) => {
const activeMode = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
style={{
height: 26,
padding: "0 12px",
borderRadius: 9999,
fontFamily: "var(--font-sans)",
fontSize: 11.5,
fontWeight: 500,
background: activeMode
? "var(--color-primary-tint)"
: "var(--color-bg-surface)",
border: `1px solid ${activeMode ? "var(--color-primary)" : "var(--color-border)"}`,
color: activeMode ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
</div>
)}
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
selected={!value ? targetWeek : ""}
onPick={(mon) => {
onChange("");
onChangeWeek?.(mon);
close();
}}
/>
) : (
<MiniCalendar
value={value}
onChange={(iso) => {
onChange(iso);
close();
}}
/>
)}
</Popover>
</>
);
}

View file

@ -0,0 +1,167 @@
import { useState, type ReactNode } from "react";
import { Check, ChevronDown } from "lucide-react";
import { Popover } from "@mui/material";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
export interface WizardMenuOption {
key: string;
label: string;
left?: ReactNode;
}
type WizardFieldSelectProps = {
value: string;
placeholder: string;
icon?: ReactNode;
options: WizardMenuOption[];
onPick: (key: string) => void;
search?: boolean;
searchPlaceholder?: string;
disabled?: boolean;
};
export function WizardFieldSelect({
value,
placeholder,
icon,
options,
onPick,
search = true,
searchPlaceholder = "Search…",
disabled = false,
}: WizardFieldSelectProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
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;
const handleClose = () => {
close();
setQ("");
};
return (
<>
<MenuAnchorButton
disabled={disabled}
onAnchor={setAnchor}
open={open}
className="flex w-full items-center gap-2 transition-colors"
style={{
height: 36,
border: "1px solid var(--color-border)",
borderRadius: 6,
background: "var(--color-bg-surface)",
padding: "0 10px",
fontFamily: "var(--font-sans)",
fontSize: 13,
color: "var(--foreground)",
outline: "none",
}}
onFocus={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "0 0 0 2px var(--color-primary)";
}}
onBlur={(e) => {
(e.currentTarget as HTMLButtonElement).style.boxShadow = "none";
}}
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 ? (
<span
className="min-w-0 flex-1 truncate text-left"
style={{ fontSize: 13, color: "var(--foreground)" }}
>
{selected.label}
</span>
) : (
<span
className="min-w-0 flex-1 truncate text-left"
style={{ fontSize: 13, color: "var(--muted-foreground)" }}
>
{placeholder}
</span>
)}
<ChevronDown size={13} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 rounded-lg border border-border p-1.5 shadow-md",
style: { width: anchorEl?.offsetWidth ?? 280 },
onClick: (e: React.MouseEvent) => e.stopPropagation(),
},
}}
>
{search && (
<div
className="mb-1 px-1 pb-1.5"
style={{ borderBottom: "1px solid var(--color-border)" }}
>
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={searchPlaceholder}
className="w-full rounded px-2 py-1 text-[12px] focus:outline-none"
style={{
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
}}
/>
</div>
)}
<div style={{ maxHeight: 220, overflowY: "auto" }}>
{filtered.length === 0 ? (
<p
className="px-2 py-2 text-center text-[12px]"
style={{ color: "var(--color-text-muted)" }}
>
No matches
</p>
) : (
filtered.map((o) => (
<button
key={o.key}
type="button"
onClick={() => {
onPick(o.key);
handleClose();
}}
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>
{value === o.key && (
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
)}
</button>
))
)}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,110 @@
import { Check, ChevronLeft, ChevronRight, Lightbulb } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
WIZARD_STEPS,
canContinueWizardStep1,
canCreateWorkOrderFromWizard,
isWizardStepValid,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
type WizardFooterProps = {
step: number;
draft: WorkOrderWizardDraft;
onCancel: () => void;
onBack: () => void;
onContinue: () => void;
onCreate: () => void;
isCreating: boolean;
};
export function WizardFooter({
step,
draft,
onCancel,
onBack,
onContinue,
onCreate,
isCreating,
}: WizardFooterProps) {
const stepValid = isWizardStepValid(step, draft);
const canCreate = canCreateWorkOrderFromWizard(draft);
const canContinue1 = canContinueWizardStep1(draft);
return (
<div
className="flex shrink-0 items-center gap-3"
style={{
borderTop: "1px solid var(--color-border)",
padding: "14px 28px",
background: "var(--color-bg-surface)",
}}
>
{step === 1 ? (
<Button
variant="ghost"
onClick={onCancel}
sx={{ fontSize: 13, color: "var(--muted-foreground)", minHeight: 36 }}
>
Cancel
</Button>
) : (
<Button
variant="outline"
onClick={onBack}
startIcon={<ChevronLeft size={14} />}
sx={{ fontSize: 13, borderColor: "var(--color-border)", minHeight: 36 }}
>
Back
</Button>
)}
<div className="flex flex-1 justify-center">
{step === 1 && !canContinue1 && (
<span
className="inline-flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
>
<Lightbulb size={13} /> Pick a type to continue
</span>
)}
</div>
{step < WIZARD_STEPS.length ? (
<Button
disabled={!stepValid}
onClick={onContinue}
endIcon={<ChevronRight size={14} />}
sx={{
background: "var(--color-primary)",
color: "#fff",
height: 36,
fontSize: 13,
fontWeight: 500,
...(!stepValid ? { opacity: 0.5, pointerEvents: "none" } : {}),
}}
>
Continue
</Button>
) : (
<Button
disabled={!canCreate || isCreating}
loading={isCreating}
loadingText="Creating…"
onClick={onCreate}
startIcon={!isCreating ? <Check size={14} /> : undefined}
sx={{
background: "var(--color-primary)",
color: "#fff",
height: 36,
fontSize: 13,
fontWeight: 500,
...(!canCreate ? { opacity: 0.5, pointerEvents: "none" } : {}),
}}
>
Create work order
</Button>
)}
</div>
);
}

View file

@ -0,0 +1,9 @@
export const DLG_INPUT_CLS =
"w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary";
export const DLG_INPUT_STYLE: React.CSSProperties = {
fontSize: 13,
border: "1px solid var(--color-border)",
color: "var(--color-text-primary)",
background: "var(--color-bg-surface)",
};

View file

@ -0,0 +1,180 @@
import type { CSSProperties, ElementType, ReactNode } from "react";
import { Check } from "lucide-react";
export function StepCircle({ n, done, active }: { n: number; done: boolean; active: boolean }) {
const base: React.CSSProperties = {
width: 28,
height: 28,
borderRadius: 9999,
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
};
if (done) {
return (
<span
className="flex shrink-0 items-center justify-center"
style={{ ...base, background: "var(--color-success)", color: "#fff" }}
>
<Check size={14} />
</span>
);
}
if (active) {
return (
<span
className="flex shrink-0 items-center justify-center"
style={{ ...base, background: "var(--color-primary)", color: "#fff" }}
>
{n}
</span>
);
}
return (
<span
className="flex shrink-0 items-center justify-center"
style={{
...base,
background: "var(--color-bg-muted)",
color: "var(--muted-foreground)",
border: "1px solid var(--color-border)",
}}
>
{n}
</span>
);
}
const WIZ_LABEL_STYLE: CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
};
function WizOptionalTag() {
return (
<span
className="shrink-0 leading-none"
style={{
fontSize: 9,
fontWeight: 400,
letterSpacing: 0,
textTransform: "none",
color: "var(--color-text-muted)",
}}
>
optional
</span>
);
}
export function WizLabel({
children,
optional,
required,
leading,
}: {
children: ReactNode;
optional?: boolean;
required?: boolean;
leading?: ReactNode;
}) {
return (
<label
className="mb-1.5 inline-flex w-full flex-nowrap items-center gap-1.5"
style={WIZ_LABEL_STYLE}
>
{leading != null && <span className="flex shrink-0 items-center">{leading}</span>}
<span className="shrink-0">{children}</span>
{required && <span style={{ color: "var(--destructive)" }}>*</span>}
{optional && <WizOptionalTag />}
</label>
);
}
export function WizSubLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "var(--foreground)",
}}
>
{children}
</div>
);
}
export function WizLabelRow({
children,
optional,
onClear,
}: {
children: ReactNode;
optional?: boolean;
onClear?: () => void;
}) {
return (
<div className="mb-1.5 flex items-center justify-between gap-2" style={{ minHeight: 16 }}>
<span
className="inline-flex min-w-0 flex-nowrap items-baseline gap-1.5"
style={WIZ_LABEL_STYLE}
>
<span className="shrink-0">{children}</span>
{optional && <WizOptionalTag />}
</span>
{onClear && (
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-0.5 transition-colors"
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "var(--foreground)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = "var(--color-text-muted)";
}}
>
× Clear
</button>
)}
</div>
);
}
export function Detail({ icon: Icon, text }: { icon: ElementType; text: string }) {
return (
<div
className="flex items-center gap-1.5"
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
>
<Icon size={12} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
<span className="truncate">{text}</span>
</div>
);
}
export function ColorDot({ color }: { color: string }) {
return (
<span
aria-hidden
className="inline-flex shrink-0 rounded-full"
style={{
width: 8,
height: 8,
minWidth: 8,
minHeight: 8,
backgroundColor: color,
}}
/>
);
}

View file

@ -0,0 +1,219 @@
import { ClipboardList } from "lucide-react";
import {
WIZARD_STATUS_DOTS,
WIZARD_STEPS,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import {
fmtDate,
fmtWeekRange,
formatApptRange,
} from "@/domain/work-orders/utils/wizard-date-utils";
import {
ColorDot,
StepCircle,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardSidebarProps = {
step: number;
draft: WorkOrderWizardDraft;
};
export function WizardSidebar({ step, draft }: WizardSidebarProps) {
const summary: [string, string][] = [
["WO #", draft.woNumber ? `#${draft.woNumber}` : "—"],
["Type", draft.type || "—"],
[
"Schedule on",
draft.scheduledOn
? draft.scheduleWeekOnly
? fmtWeekRange(draft.scheduledOn)
: fmtDate(draft.scheduledOn)
: "—",
],
["Due date", fmtDate(draft.dueDate) || "—"],
["Assigned to", draft.assigneeName || "—"],
["Site", draft.locationName || "—"],
[
"POC",
draft.pocName
? draft.pocPhone
? `${draft.pocName} · ${draft.pocPhone}`
: draft.pocName
: "—",
],
["Service", draft.pm || "—"],
["Technician", draft.vendorName || "—"],
["Company", draft.vendorName || "—"],
["Appt time", draft.apptStart ? formatApptRange(draft.apptStart, draft.apptEnd) : "—"],
["Status", draft.status || "Incomplete"],
];
const statusLabel = draft.status || "Incomplete";
const statusValue = statusLabel as WizardWOStatus;
const statusDotColor = WIZARD_STATUS_DOTS[statusValue] ?? WIZARD_STATUS_DOTS.Incomplete;
return (
<div
className="flex shrink-0 flex-col overflow-y-auto"
style={{
width: 260,
background: "var(--color-bg-muted)",
borderRight: "1px solid var(--color-border)",
padding: 24,
}}
>
<div className="flex items-center gap-2.5">
<span
className="flex shrink-0 items-center justify-center"
style={{ width: 34, height: 34, borderRadius: 8, background: "var(--color-primary)" }}
>
<ClipboardList size={18} color="#fff" />
</span>
<div className="min-w-0 leading-tight">
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
}}
>
Operations
</div>
<div
style={{
fontFamily: "var(--font-display)",
fontWeight: 700,
fontSize: 16,
color: "var(--foreground)",
lineHeight: 1.2,
marginTop: 1,
}}
>
New work order
</div>
</div>
</div>
<div className="flex flex-col" style={{ marginTop: 20 }}>
{WIZARD_STEPS.map((s, i) => {
const n = i + 1;
const done = n < step;
const active = n === step;
const last = i === WIZARD_STEPS.length - 1;
return (
<div key={s.title} className="flex gap-2.5">
<div className="flex flex-col items-center">
<StepCircle n={n} done={done} active={active} />
{!last && (
<div
style={{
width: 2,
flex: 1,
minHeight: 16,
marginTop: 2,
background: done ? "var(--color-success)" : "var(--color-border)",
}}
/>
)}
</div>
<div style={{ paddingBottom: last ? 0 : 14 }}>
<div
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active || done ? "var(--foreground)" : "var(--muted-foreground)",
lineHeight: 1.2,
}}
>
{s.title}
</div>
<div
style={{
fontFamily: "var(--font-sans)",
fontSize: 11,
color: "var(--muted-foreground)",
marginTop: 2,
}}
>
{s.nav}
</div>
</div>
</div>
);
})}
</div>
<div style={{ marginTop: "auto", paddingTop: 44 }}>
<div
style={{
fontFamily: "var(--font-mono)",
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.08em",
textTransform: "uppercase",
color: "var(--muted-foreground)",
marginBottom: 8,
}}
>
This work order
</div>
{summary.map(([label, val], i) => (
<div
key={label}
className="flex items-center justify-between gap-2"
style={{
padding: "4px 0",
borderBottom: i < summary.length - 1 ? "1px solid var(--color-border)" : "none",
}}
>
<span
className="shrink-0"
style={{
fontFamily: "var(--font-sans)",
fontSize: 11,
fontWeight: 400,
color: "var(--muted-foreground)",
}}
>
{label}
</span>
{label === "Status" ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<ColorDot color={statusDotColor} />
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{statusLabel}
</span>
</span>
) : (
<span
className="truncate text-right"
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 500,
color: "var(--foreground)",
}}
>
{val}
</span>
)}
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,167 @@
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
Detail,
WizLabel,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type ServiceOption = { id: string | number; value: string };
type WizardStepLocationServiceProps = {
draft: WorkOrderWizardDraft;
locations: LocationOption[];
locationDetail: Location | undefined;
services: ServiceOption[];
onUpdate: <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)}`;
}
export function WizardStepLocationService({
draft,
locations,
locationDetail,
services,
onUpdate,
onPatch,
}: WizardStepLocationServiceProps) {
return (
<div className="space-y-5">
<div>
<WizLabel required>Site</WizLabel>
<WizardFieldSelect
value={draft.locationId}
placeholder="Select site…"
icon={
<MapPin size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
searchPlaceholder="Search site code or city…"
options={locations.map((loc) => ({
key: String(loc.id),
label: loc.name,
}))}
onPick={(id) => {
const loc = locations.find((l) => String(l.id) === id);
onPatch({
locationId: id,
locationName: loc?.name ?? "",
});
}}
/>
{!draft.locationId && (
<p
className="mt-1.5 flex items-center gap-1"
style={{ fontSize: 11, color: "var(--muted-foreground)" }}
>
<Info size={12} className="shrink-0" /> Site is required to continue.
</p>
)}
</div>
{locationDetail && 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>
)}
</div>
<div>
<WizSubLabel>Point of contact</WizSubLabel>
<p style={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}>
Auto-filled from records — edit if outdated
</p>
<div className="grid grid-cols-2 gap-2">
<input
value={draft.pocName}
onChange={(e) => onUpdate("pocName", e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={draft.pocPhone}
onChange={(e) => onUpdate("pocPhone", e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<textarea
value={draft.pocNotes}
onChange={(e) => onUpdate("pocNotes", e.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</>
)}
<div>
<WizLabel optional>Service</WizLabel>
<WizardFieldSelect
value={draft.pm}
placeholder="Select service…"
icon={
<MapPin 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)}
/>
</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

@ -0,0 +1,196 @@
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 { WizardDateField } from "@/app/(protected)/workorders/_components/wizard/wizard-date-field";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
ColorDot,
WizLabel,
WizLabelRow,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { CheckCircle2, Clock, Flame, Plus, Users, Wrench, Zap } from "lucide-react";
import type { ElementType } from "react";
const WO_TYPE_ICONS: Record<WOType, ElementType> = {
PM: Wrench,
Reactive: Zap,
Emergency: Flame,
"Add-On": Plus,
Overdue: Clock,
};
type WizardStepTypeScheduleProps = {
draft: WorkOrderWizardDraft;
users: WorkOrderUser[];
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
export function WizardStepTypeSchedule({
draft,
users,
onUpdate,
onPatch,
}: WizardStepTypeScheduleProps) {
return (
<div className="space-y-6">
<div>
<WizLabel required>Type of WO</WizLabel>
<div className="auto-rows-fr grid grid-cols-2 gap-3">
{WO_TYPES.map((t) => {
const selected = draft.type === t;
const meta = WO_TYPE_META[t];
const Icon = WO_TYPE_ICONS[t];
return (
<button
key={t}
type="button"
onClick={() => onUpdate("type", t)}
className="relative flex items-start gap-2.5 text-left transition-colors"
style={{
border: selected
? "2px solid var(--color-primary)"
: "1px solid var(--color-border)",
borderRadius: 8,
background: selected ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
padding: selected ? "13px 15px" : "14px 16px",
}}
onMouseEnter={(e) => {
if (!selected) {
const b = e.currentTarget;
b.style.borderColor =
"color-mix(in srgb, var(--color-primary) 40%, transparent)";
b.style.background = "var(--color-bg-muted)";
}
}}
onMouseLeave={(e) => {
if (!selected) {
const b = e.currentTarget;
b.style.borderColor = "var(--color-border)";
b.style.background = "var(--color-bg-surface)";
}
}}
>
<span
className="flex shrink-0 items-center justify-center"
style={{ width: 30, height: 30, borderRadius: 8, background: meta.bg }}
>
<Icon size={16} style={{ color: meta.fg }} />
</span>
<div className="min-w-0">
<div
style={{
fontFamily: "var(--font-sans)",
fontSize: 13,
fontWeight: 600,
color: "var(--foreground)",
}}
>
{t}
</div>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: 11,
fontWeight: 400,
color: "var(--muted-foreground)",
marginTop: 2,
lineHeight: 1.35,
}}
>
{meta.desc}
</p>
</div>
{selected && (
<CheckCircle2
size={14}
className="absolute"
style={{ top: 8, right: 8, color: "var(--color-primary)" }}
/>
)}
</button>
);
})}
</div>
</div>
<div>
<WizLabel optional>WO Number</WizLabel>
<input
type="text"
value={draft.woNumber}
onChange={(e) => onUpdate("woNumber", e.target.value)}
placeholder="e.g. 24818"
maxLength={20}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizLabel optional>Assigned To</WizLabel>
<WizardFieldSelect
value={draft.assigneeId}
placeholder="Select…"
icon={
<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)" />,
}))}
onPick={(id) => {
const user = users.find((u) => String(u.id) === id);
onPatch({
assigneeId: id,
assigneeName: user?.name ?? "",
});
}}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<WizLabelRow
optional
onClear={
draft.scheduledOn !== "" || draft.scheduleWeekOnly
? () => onPatch({ scheduledOn: "", scheduleWeekOnly: false })
: undefined
}
>
Schedule On
</WizLabelRow>
<WizardDateField
value={draft.scheduleWeekOnly ? "" : draft.scheduledOn}
targetWeek={draft.scheduleWeekOnly ? draft.scheduledOn : ""}
placeholder="Pick a date"
allowWeekMode
onChange={(iso) => onPatch({ scheduledOn: iso, scheduleWeekOnly: false })}
onChangeWeek={(w) => onPatch({ scheduledOn: w, scheduleWeekOnly: Boolean(w) })}
/>
</div>
<div>
<WizLabelRow
optional
onClear={draft.dueDate !== "" ? () => onUpdate("dueDate", "") : undefined}
>
Due Date
</WizLabelRow>
<WizardDateField
value={draft.dueDate}
placeholder="No due date"
onChange={(iso) => onUpdate("dueDate", iso)}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,208 @@
import { Clock, ExternalLink, Mail, Wrench } from "lucide-react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import {
ALL_WIZARD_STATUSES,
TIME_SLOTS,
WIZARD_STATUS_DOTS,
type WizardWOStatus,
type WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import { calcDuration, fmt12 } from "@/domain/work-orders/utils/wizard-date-utils";
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
import {
DLG_INPUT_CLS,
DLG_INPUT_STYLE,
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
import {
ColorDot,
Detail,
WizLabel,
WizLabelRow,
WizSubLabel,
} from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
type WizardStepVendorTimeProps = {
draft: WorkOrderWizardDraft;
vendors: VendorDropdownItem[];
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
onPatch: (patch: Partial<WorkOrderWizardDraft>) => void;
};
export function WizardStepVendorTime({
draft,
vendors,
onUpdate,
onPatch,
}: WizardStepVendorTimeProps) {
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;
return (
<div className="space-y-5">
<div>
<WizLabelRow
optional
onClear={
draft.vendorId !== ""
? () => onPatch({ vendorId: "", vendorName: "", techPhone: "" })
: undefined
}
>
Technician
</WizLabelRow>
<WizardFieldSelect
value={draft.vendorId}
placeholder="Select technician…"
icon={
selectedVendor ? (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full"
style={{
width: 20,
height: 20,
fontSize: 9,
fontWeight: 600,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-secondary)",
}}
>
{selectedVendor.companyName.slice(0, 2).toUpperCase()}
</span>
) : (
<Wrench size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
)
}
searchPlaceholder="Search technician…"
options={vendors.map((v) => ({
key: String(v.id),
label: v.companyName,
}))}
onPick={(id) => {
const vendor = vendors.find((v) => String(v.id) === id);
onPatch({
vendorId: id,
vendorName: vendor?.companyName ?? "",
techPhone: "",
});
}}
/>
</div>
{draft.vendorId && selectedVendor && (
<>
<div>
<WizSubLabel>Technician phone</WizSubLabel>
<input
value={draft.techPhone}
onChange={(e) => onUpdate("techPhone", e.target.value)}
placeholder="(000) 000-0000"
className={`${DLG_INPUT_CLS} mt-2`}
style={DLG_INPUT_STYLE}
/>
</div>
<div>
<WizSubLabel>Company</WizSubLabel>
<div
className="mt-2 space-y-1.5 rounded-lg p-3"
style={{
background: "var(--color-bg-muted)",
border: "1px solid var(--color-border)",
}}
>
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
{selectedVendor.companyName || "—"}
</div>
{selectedVendor.address && <Detail icon={Mail} text={selectedVendor.address} />}
{selectedVendor.address && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
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>
)}
</div>
</div>
<div>
<WizSubLabel>Notes</WizSubLabel>
<textarea
value={draft.vendorNotes}
onChange={(e) => onUpdate("vendorNotes", e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={2}
className={`${DLG_INPUT_CLS} mt-2 resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</>
)}
<div>
<WizLabel optional>Appt Time</WizLabel>
<div className="flex items-center gap-2">
<WizardFieldSelect
value={draft.apptStart}
placeholder="Start"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
search={false}
options={TIME_SLOTS.map((t) => ({ key: t, label: fmt12(t) }))}
onPick={(t) =>
onPatch({
apptStart: t,
apptEnd: draft.apptEnd && draft.apptEnd <= t ? "" : draft.apptEnd,
})
}
/>
<span className="shrink-0 text-[12px]" style={{ color: "var(--muted-foreground)" }}>
to
</span>
<WizardFieldSelect
value={draft.apptEnd}
placeholder="End"
icon={
<Clock size={16} className="shrink-0" style={{ color: "var(--muted-foreground)" }} />
}
search={false}
disabled={!draft.apptStart}
options={TIME_SLOTS.filter((t) => !draft.apptStart || t > draft.apptStart).map((t) => ({
key: t,
label: fmt12(t),
}))}
onPick={(t) => onUpdate("apptEnd", t)}
/>
</div>
{draft.apptStart && draft.apptEnd && calcDuration(draft.apptStart, draft.apptEnd) && (
<p
className="mt-2 flex items-center gap-1.5"
style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
>
<Clock size={12} /> Duration {calcDuration(draft.apptStart, draft.apptEnd)}
</p>
)}
</div>
<div>
<WizLabel>Status</WizLabel>
<WizardFieldSelect
value={statusValue}
placeholder="Select status…"
search={false}
icon={<ColorDot color={statusDotColor} />}
options={ALL_WIZARD_STATUSES.map((s) => ({
key: s,
label: s,
left: <ColorDot color={WIZARD_STATUS_DOTS[s]} />,
}))}
onPick={(s) => onUpdate("status", s as WizardWOStatus)}
/>
</div>
</div>
);
}