Merge remote-tracking branch 'origin/dev' into codex/pr30-lint-fix

This commit is contained in:
Alexandre Brandizzi 2026-07-24 18:46:55 -03:00
commit fce531aeb0
9 changed files with 1359 additions and 3 deletions

View file

@ -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>
);
}

View file

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

View file

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

View file

@ -0,0 +1,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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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");
});