mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-02 06:13:13 +00:00
feat(work-orders): add board wizard shell UI
This commit is contained in:
parent
7c7ac6068e
commit
84177475e7
12 changed files with 1846 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)",
|
||||
};
|
||||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue