mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 22:52:06 +00:00
feat(work-orders): add board wizard shell UI
This commit is contained in:
parent
7df076f011
commit
7fb4334405
7 changed files with 1267 additions and 0 deletions
|
|
@ -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,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,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,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