mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge pull request #43 from Sea-Haven-Industries/feature/wo-wizard-shell
feat(work-orders): add board wizard shell UI
This commit is contained in:
commit
2d2fe0d9f9
9 changed files with 1359 additions and 3 deletions
|
|
@ -0,0 +1,227 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Dialog } from "@mui/material";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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[];
|
||||
};
|
||||
|
||||
function WizardStepHeader({ step }: { step: number }) {
|
||||
return (
|
||||
<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>
|
||||
<Text
|
||||
variant="title"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
color: "var(--foreground)",
|
||||
marginTop: "6px",
|
||||
}}
|
||||
>
|
||||
{WIZARD_STEPS[step - 1].title}
|
||||
</Text>
|
||||
<Text
|
||||
variant="description"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{WIZARD_STEPS[step - 1].subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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)" }}
|
||||
>
|
||||
<WizardStepHeader step={step} />
|
||||
|
||||
{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(),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{Boolean(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,225 @@
|
|||
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) {
|
||||
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>
|
||||
|
||||
<WizardSidebarSummary draft={draft} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WizardSidebarSummary({ draft }: { draft: WorkOrderWizardDraft }) {
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { ExternalLink, Info, Mail, MapPin, Phone } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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 && (
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
className="mt-1.5 flex items-center gap-1"
|
||||
sx={{ fontSize: 11, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Info size={12} className="shrink-0" /> Site is required to continue.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{locationDetail != null && 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(", ") || "—"
|
||||
}
|
||||
/>
|
||||
{Boolean(locationDetail.phone) && (
|
||||
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
||||
)}
|
||||
{Boolean(locationDetail.contactEmail) && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||
)}
|
||||
{Boolean(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>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)", margin: "2px 0 8px" }}
|
||||
>
|
||||
Auto-filled from records — edit if outdated
|
||||
</Text>
|
||||
<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 && (
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{ fontSize: 12, fontStyle: "italic", color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Both are optional — leave blank to fill in later from the dispatch board.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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,
|
||||
};
|
||||
|
||||
function WoTypePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: WOType | "";
|
||||
onSelect: (type: WOType) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="auto-rows-fr grid grid-cols-2 gap-3">
|
||||
{WO_TYPES.map((t) => {
|
||||
const isSelected = selected === t;
|
||||
const meta = WO_TYPE_META[t];
|
||||
const Icon = WO_TYPE_ICONS[t];
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => onSelect(t)}
|
||||
className="relative flex items-start gap-2.5 text-left transition-colors"
|
||||
style={{
|
||||
border: isSelected
|
||||
? "2px solid var(--color-primary)"
|
||||
: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
background: isSelected ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||
padding: isSelected ? "13px 15px" : "14px 16px",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) {
|
||||
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 (!isSelected) {
|
||||
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>
|
||||
<Text
|
||||
as="p"
|
||||
variant="description"
|
||||
sx={{
|
||||
fontSize: 11,
|
||||
fontWeight: 400,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: "2px",
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{meta.desc}
|
||||
</Text>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<CheckCircle2
|
||||
size={14}
|
||||
className="absolute"
|
||||
style={{ top: 8, right: 8, color: "var(--color-primary)" }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<WoTypePicker selected={draft.type} onSelect={(t) => onUpdate("type", t)} />
|
||||
</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,234 @@
|
|||
import { Clock, ExternalLink, Mail, Wrench } from "lucide-react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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;
|
||||
};
|
||||
|
||||
function WizardVendorDetail({
|
||||
vendor,
|
||||
techPhone,
|
||||
vendorNotes,
|
||||
onUpdate,
|
||||
}: {
|
||||
vendor: VendorDropdownItem;
|
||||
techPhone: string;
|
||||
vendorNotes: string;
|
||||
onUpdate: <K extends keyof WorkOrderWizardDraft>(key: K, value: WorkOrderWizardDraft[K]) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<WizSubLabel>Technician phone</WizSubLabel>
|
||||
<input
|
||||
value={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)" }}>
|
||||
{vendor.companyName || "—"}
|
||||
</div>
|
||||
{Boolean(vendor.address) && <Detail icon={Mail} text={vendor.address} />}
|
||||
{Boolean(vendor.address) && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(vendor.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={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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 != null && (
|
||||
<WizardVendorDetail
|
||||
vendor={selectedVendor}
|
||||
techPhone={draft.techPhone}
|
||||
vendorNotes={draft.vendorNotes}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 !== "" &&
|
||||
Boolean(calcDuration(draft.apptStart, draft.apptEnd)) && (
|
||||
<Text
|
||||
as="p"
|
||||
variant="caption"
|
||||
className="mt-2 flex items-center gap-1.5"
|
||||
sx={{ fontSize: 11.5, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Clock size={12} /> Duration {calcDuration(draft.apptStart, draft.apptEnd)}
|
||||
</Text>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_c
|
|||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||
|
|
@ -26,6 +27,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
|||
export default function WorkOrdersListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthContext();
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const filters = useWorkOrdersListFilters(user?.id);
|
||||
|
|
@ -69,6 +71,7 @@ export default function WorkOrdersListPage() {
|
|||
search={filters.search}
|
||||
onSearchChange={filters.handleSearchChange}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onNewWorkOrder={() => setWizardOpen(true)}
|
||||
activeFilterCount={filters.activeFilterCount}
|
||||
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||
/>
|
||||
|
|
@ -126,6 +129,8 @@ export default function WorkOrdersListPage() {
|
|||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
{activeMediaRow != null && (
|
||||
<MediaUploadModal
|
||||
row={activeMediaRow}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { fireEvent, screen, waitForElementToBeRemoved } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||
import { CompDocCell } from "@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell";
|
||||
|
|
@ -133,6 +133,17 @@ vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-ca
|
|||
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||
useLocationDetail: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/use-cases/use-create-work-order-from-wizard", () => ({
|
||||
useCreateWorkOrderFromWizard: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
|
||||
isWoTableMockEnabled: () => false,
|
||||
}));
|
||||
|
|
@ -179,14 +190,24 @@ describe("WorkOrdersListHeader slice affordances", () => {
|
|||
});
|
||||
|
||||
describe("WorkOrdersListPage board-core public interface", () => {
|
||||
it("does not render New WO, and View navigates to detail", () => {
|
||||
it("opens New WO wizard on click, enforces step validation, closes, and View navigates to detail", async () => {
|
||||
navigate.mockReset();
|
||||
|
||||
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new wo/i }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /type & schedule/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/pick a type to continue/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /continue/i })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
||||
expect(navigate).toHaveBeenCalledWith("/workorders/77");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue