fix(work-orders): include table cells so board-core slice builds

Bring cell modules and minimal wizard helpers into table-core for a self-contained slice build.
This commit is contained in:
Arthur Bassi 2026-07-22 13:19:49 -03:00 • committed by Alexandre Brandizzi
parent fef7918f6b
commit 0709bd1172
26 changed files with 2569 additions and 320 deletions

View file

@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { Clock } from "lucide-react";
import { Button, Popover } from "@mui/material";
import {
calcDuration,
fmt12,
formatApptRange,
formatApptTimeValue,
parseApptTime,
TIME_SLOTS,
} from "@/domain/work-orders/utils/wizard-date-utils";
import { HoverChevron } from "./hover-chevron";
type ApptTimeCellProps = {
apptTime: string;
onSave: (value: string) => void;
};
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
const [end, setEnd] = useState(() => parseApptTime(apptTime).end);
useEffect(() => {
const p = parseApptTime(apptTime);
setStart(p.start);
setEnd(p.end);
}, [apptTime]);
const parsed = parseApptTime(apptTime);
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
const slotStyle = (active: boolean): React.CSSProperties => ({
background: active ? "var(--color-primary-tint)" : "transparent",
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
fontWeight: active ? 600 : 400,
});
const done = () => {
onSave(formatApptTimeValue(start, end));
setAnchorEl(null);
};
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
{display && display !== "—" ? (
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
{display}
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
>
<div className="grid grid-cols-2 border-b border-border">
<div className="border-r border-border">
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Start
</p>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.map((t) => (
<button
key={t}
type="button"
onClick={() => {
setStart(t);
if (end && end <= t) {
setEnd("");
}
}}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(start === t)}
>
{fmt12(t)}
</button>
))}
</div>
</div>
<div>
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
End <span className="font-normal normal-case">(optional)</span>
</p>
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
<button
key={t}
type="button"
onClick={() => setEnd(t)}
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
style={slotStyle(end === t)}
>
{fmt12(t)}
</button>
))}
</div>
</div>
</div>
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs text-[var(--color-text-muted)]">
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
</span>
<Button size="small" variant="contained" onClick={done} disabled={!start}>
Done
</Button>
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { Check, Users } from "lucide-react";
import { Popover } from "@mui/material";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { DispatcherPill } from "./dispatcher-pill";
import { HoverChevron } from "./hover-chevron";
type AssignedToCellProps = {
dispatcherId: string;
users: WorkOrderUser[];
highlight?: boolean;
onSave: (id: string, name: string) => void;
};
export function AssignedToCell({ dispatcherId, users, highlight, onSave }: AssignedToCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const selected = users.find((d) => String(d.id) === dispatcherId);
const filtered = search
? users.filter((d) => d.name.toLowerCase().includes(search.toLowerCase()))
: users;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 6px",
margin: "0 -6px",
}
: undefined
}
>
{selected ? (
<DispatcherPill user={selected} />
) : (
<span className="flex items-center gap-1.5 text-[13px] text-[var(--color-text-muted)]">
<Users
size={13}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
Select …
</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setSearch("");
}}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[212px] rounded-lg border border-border p-1.5" } }}
>
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search dispatcher…"
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>
<div className="max-h-52 overflow-y-auto">
{filtered.map((d) => (
<button
key={String(d.id)}
type="button"
onClick={() => {
onSave(String(d.id), d.name);
setAnchorEl(null);
setSearch("");
}}
className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 transition-colors hover:bg-[#F9FAFB]"
>
<DispatcherPill user={d} />
{dispatcherId === String(d.id) && (
<Check size={10} className="shrink-0 text-[var(--color-primary)]" />
)}
</button>
))}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,67 @@
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
function isMediaWO(type: WOType | ""): boolean {
return type === "Emergency" || type === "Reactive";
}
type CompDocCellProps = {
status: CompletionDocStatus;
woType: WOType | "";
mediaCount?: number;
onOpen: () => void;
onOpenMedia: () => void;
};
export function CompDocCell({
status,
woType,
mediaCount = 0,
onOpen,
onOpenMedia,
}: CompDocCellProps) {
if (isMediaWO(woType)) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenMedia();
}}
title="Photos & videos"
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold text-[var(--color-primary)] transition-opacity hover:opacity-80"
>
<ImageIcon size={15} className="shrink-0 text-[var(--color-primary)]" />
{mediaCount > 0 ? `${mediaCount} file${mediaCount !== 1 ? "s" : ""}` : "Media"}
</button>
);
}
const generated = status === "Yes";
const notRequired = status === "NN";
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
const Icon = notRequired ? Ban : FileText;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpen();
}}
title={
generated
? "View completion document"
: notRequired
? "Completion document — not required"
: "Completion document — draft"
}
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold transition-opacity hover:opacity-80"
style={{ color }}
>
<Icon size={15} className="shrink-0" style={{ color }} />
{generated ? "View Doc" : notRequired ? "N/N" : "Draft"}
</button>
);
}

View file

@ -0,0 +1,139 @@
import { useState } from "react";
import { CalendarDays, Calendar as 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 { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
import { HighlightText } from "./highlight-text";
type DateCellProps = {
value: string;
q?: string;
highlight?: boolean;
onSave: (date: string) => void;
allowWeekMode?: boolean;
targetWeek?: string;
onSaveWeek?: (weekStart: string) => void;
defaultMode?: "week" | "date";
};
export function DateCell({
value,
q,
highlight,
onSave,
allowWeekMode,
targetWeek = "",
onSaveWeek,
defaultMode,
}: DateCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [cellMode, setCellMode] = useState<"week" | "date">(
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
);
const showWeek = allowWeekMode && !value && !!targetWeek;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
style={
highlight
? {
boxShadow: "0 0 0 2px var(--color-accent)",
background: "var(--color-primary-tint)",
padding: "2px 8px",
margin: "0 -8px",
}
: undefined
}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
{showWeek ? (
<>
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
{fmtWeekOf(targetWeek)}
</span>
</>
) : (
<>
<CalendarIcon
size={11}
className="shrink-0"
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
/>
{value ? (
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
</span>
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
)}
</>
)}
</span>
<ChevronDown
size={14}
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
>
{allowWeekMode && (
<div className="mb-2.5 flex items-center gap-1.5">
{(["week", "date"] as const).map((m) => {
const active = cellMode === m;
return (
<button
key={m}
type="button"
onClick={() => setCellMode(m)}
className="rounded-full border px-3 text-[11.5px] font-medium"
style={{
height: 26,
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
}}
>
{m === "week" ? "Week" : "Specific date"}
</button>
);
})}
</div>
)}
{allowWeekMode && cellMode === "week" ? (
<WeekPicker
selected={!value ? targetWeek : ""}
onPick={(mon) => {
onSave("");
onSaveWeek?.(mon);
setAnchorEl(null);
}}
/>
) : (
<MiniCalendar
value={value}
onChange={(d) => {
onSave(d);
setAnchorEl(null);
}}
/>
)}
</Popover>
</>
);
}

View file

@ -0,0 +1,40 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import { getDispatcherAvatarColor } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
import { HighlightText } from "./highlight-text";
type DispatcherPillProps = {
user: WorkOrderUser;
q?: string;
};
export function DispatcherPill({ user, q }: DispatcherPillProps) {
const color = getDispatcherAvatarColor(user);
return (
<span
className="inline-flex max-w-full items-center truncate whitespace-nowrap leading-none"
style={{
height: 26,
padding: "0 11px",
borderRadius: 9999,
border: `1.5px solid ${color}`,
background: "var(--card)",
color,
fontSize: 12.5,
fontWeight: 600,
}}
>
<HighlightText text={user.name} q={q} />
</span>
);
}

View file

@ -0,0 +1,212 @@
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { HighlightText } from "./highlight-text";
type EditableWONumberCellProps = {
value: string;
rescheduleCount: number;
carriedOver?: number;
q?: string;
currentId: string | number;
allOrders: WorkOrderTableRow[];
onSave: (v: string) => void;
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
};
function CounterPill({ label, count }: { label: string; count: number }) {
if (count < 2) {
return null;
}
return (
<span
title={label}
className="inline-flex h-[18px] items-center rounded px-1.5 text-[10px] font-semibold"
style={{
background: "var(--color-bg-muted)",
color: "var(--color-text-secondary)",
border: "1px solid var(--color-border)",
}}
>
{count}
</span>
);
}
export function EditableWONumberCell({
value,
rescheduleCount,
carriedOver = 0,
q,
currentId,
allOrders,
onSave,
onDuplicateFound,
}: EditableWONumberCellProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
useEffect(() => {
if (!editing) {
setDraft(value);
}
}, [value, editing]);
const findDuplicate = (d: string) =>
allOrders.find((o) => o.woNumber === d && String(o.id) !== String(currentId));
const commit = () => {
if (draft === value) {
setEditing(false);
return;
}
if (draft.length !== 11) {
toast("Work order number must be exactly 11 digits");
return;
}
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
setEditing(false);
};
const cancel = () => {
setDraft(value);
setEditing(false);
};
const handleBlur = () => {
if (draft !== value && draft.length === 11) {
const duplicate = findDuplicate(draft);
if (duplicate) {
onDuplicateFound?.(duplicate);
setDraft(value);
setEditing(false);
return;
}
onSave(draft);
}
setEditing(false);
};
const invalid = editing && draft.length > 0 && draft.length !== 11;
return (
<div className="flex items-center gap-1.5">
{editing ? (
<input
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 11))}
onFocus={(e) => e.target.select()}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={handleBlur}
placeholder="12345678901"
aria-label="Work order number"
aria-invalid={invalid}
className="font-mono focus:outline-none"
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--foreground)",
background: "var(--color-bg-surface)",
border: `1px solid ${invalid ? "var(--color-destructive)" : "var(--color-ring)"}`,
boxShadow: `0 0 0 1px ${invalid ? "var(--color-destructive)" : "var(--color-accent)"}`,
borderRadius: 4,
padding: "2px 4px",
width: 120,
}}
/>
) : (
<span
onClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
className="inline-block cursor-text font-mono transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
fontSize: 13,
fontWeight: 700,
color: value ? "var(--foreground)" : "var(--color-text-muted)",
borderRadius: 4,
padding: "2px 4px",
}}
>
{value ? <HighlightText text={value} q={q} /> : "-"}
</span>
)}
<CounterPill label="Reschedules" count={rescheduleCount} />
<CounterPill label="Carried over" count={carriedOver} />
</div>
);
}

View file

@ -0,0 +1,99 @@
import { useState } from "react";
import { Flag, X } from "lucide-react";
import { Popover } from "@mui/material";
const FLAG_COLORS: { name: string; value: string }[] = [
{ name: "Red", value: "#EF4444" },
{ name: "Orange", value: "#F97316" },
{ name: "Amber", value: "#F59E0B" },
{ name: "Yellow", value: "#EAB308" },
{ name: "Green", value: "#22C55E" },
{ name: "Teal", value: "#14B8A6" },
{ name: "Blue", value: "#3B82F6" },
{ name: "Purple", value: "#8B5CF6" },
];
type FlagCellProps = {
color?: string;
disabled?: boolean;
onChange: (color: string | null) => void;
};
export function FlagCell({ color, disabled, onChange }: FlagCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (disabled) {
return (
<span className="flex h-[22px] w-[22px] items-center justify-center opacity-30">
<Flag size={14} />
</span>
);
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
title={color ? "Flagged — click to change" : "Add color flag"}
className="flex h-[22px] w-[22px] items-center justify-center transition-opacity hover:opacity-80"
>
<Flag
size={14}
strokeWidth={color ? 1.5 : 1.75}
style={{
color: color ?? "var(--color-text-muted)",
fill: color ?? "transparent",
}}
/>
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }}
>
<div className="grid grid-cols-4 gap-1.5">
{FLAG_COLORS.map((c) => (
<button
key={c.value}
type="button"
title={c.name}
onClick={() => {
onChange(c.value);
setAnchorEl(null);
}}
className="flex h-7 w-7 items-center justify-center rounded transition-transform hover:scale-110"
>
<span
className="rounded-full"
style={{
width: 18,
height: 18,
background: c.value,
boxShadow:
color === c.value ? `0 0 0 2px var(--card), 0 0 0 4px ${c.value}` : "none",
}}
/>
</button>
))}
</div>
<button
type="button"
onClick={() => {
onChange(null);
setAnchorEl(null);
}}
className="mt-2 flex w-full items-center gap-1.5 border-t border-border px-1 pt-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:opacity-80"
>
<X size={12} /> Clear flag
</button>
</Popover>
</>
);
}

View file

@ -0,0 +1,26 @@
type HighlightTextProps = {
text: string;
q?: string;
};
export function HighlightText({ text, q }: HighlightTextProps) {
if (!q?.trim() || !text) {
return <>{text}</>;
}
const query = q.trim();
const lower = text.toLowerCase();
const idx = lower.indexOf(query.toLowerCase());
if (idx < 0) {
return <>{text}</>;
}
const before = text.slice(0, idx);
const match = text.slice(idx, idx + query.length);
const after = text.slice(idx + query.length);
return (
<>
{before}
<mark className="rounded-sm bg-[var(--color-primary-tint)] px-0.5 text-inherit">{match}</mark>
{after}
</>
);
}

View file

@ -0,0 +1,11 @@
import { ChevronDown } from "lucide-react";
export function HoverChevron() {
return (
<ChevronDown
size={14}
className="ml-auto shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
style={{ color: "var(--muted-foreground)" }}
/>
);
}

View file

@ -0,0 +1,120 @@
import { useState } from "react";
import { Check } from "lucide-react";
import { Popover } from "@mui/material";
import { PmTypeIcon } from "../pm-type-icons";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
type PMTypeCellProps = {
value: string;
pmTypes: string[];
q?: string;
onSave: (v: string) => void;
};
export function PMTypeCell({ value, pmTypes, q, onSave }: PMTypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [search, setSearch] = useState("");
const filtered = search
? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase()))
: pmTypes;
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
>
<PmTypeIcon
pmType={value}
size={12}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
{value ? (
<span
className="min-w-0 truncate"
style={{
fontSize: 13,
color: "var(--color-text-primary)",
}}
>
<HighlightText text={value} q={q} />
</span>
) : (
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setSearch("");
}}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
>
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search PM type…"
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
/>
</div>
<div className="max-h-52 overflow-y-auto">
{filtered.map((pm) => (
<button
key={pm}
type="button"
onClick={() => {
onSave(pm);
setAnchorEl(null);
setSearch("");
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
<span
className="flex-1 truncate text-left text-xs"
style={{ color: "var(--color-text-primary)" }}
>
{pm}
</span>
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
</button>
))}
</div>
</Popover>
</>
);
}

View file

@ -0,0 +1,79 @@
import { useState } from "react";
import { MapPin } from "lucide-react";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { SiteDialog, type SitePatch } from "./site-dialog";
type SiteCellProps = {
value: string;
location: string;
locationId: string | number;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
q?: string;
onSave: (patch: SitePatch) => void;
};
export function SiteCell({
value,
location,
locationId,
pocName,
pocPhone,
pocNotes,
sites,
q,
onSave,
}: SiteCellProps) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full min-w-0 items-center gap-1.5 transition-opacity hover:opacity-80"
>
{value ? (
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
<MapPin size={13} className="shrink-0 text-[var(--color-text-muted)]" />
<span
className="shrink-0 font-mono text-[12.5px] font-bold tracking-wide"
style={{ color: "var(--foreground)" }}
>
<HighlightText text={value} q={q} />
</span>
{location && (
<>
<span className="shrink-0 text-[var(--color-text-muted)]">·</span>
<span className="truncate text-xs text-[var(--muted-foreground)]">
<HighlightText text={location} q={q} />
</span>
</>
)}
</span>
) : (
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<SiteDialog
open={open}
onOpenChange={setOpen}
value={value}
locationId={locationId}
pocName={pocName}
pocPhone={pocPhone}
pocNotes={pocNotes}
sites={sites}
onSave={onSave}
/>
</>
);
}

View file

@ -0,0 +1,270 @@
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
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 } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { Location } from "@/domain/locations/types/location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
export type SitePatch = {
site: string;
location: string;
locationId: string | number;
pocName: string;
pocPhone: string;
pocNotes: string;
};
type SiteDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
value: string;
locationId: string | number;
pocName?: string;
pocPhone?: string;
pocNotes?: string;
sites: LocationOption[];
onSave: (patch: SitePatch) => void;
};
function FieldLabel({ children }: { children: ReactNode }) {
return (
<label
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--color-text-secondary)" }}
>
{children}
</label>
);
}
function extractSiteCode(name: string): string {
const dash = name.indexOf(" — ");
if (dash > 0) {
return name.slice(0, dash).trim();
}
return name.slice(0, 8).toUpperCase();
}
function extractLocationLabel(name: string): string {
const dash = name.indexOf(" — ");
if (dash > 0) {
return name.slice(dash + 3).trim();
}
return name;
}
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 SiteDialog({
open,
onOpenChange,
value,
locationId,
pocName = "",
pocPhone = "",
pocNotes = "",
sites,
onSave,
}: SiteDialogProps) {
const [code, setCode] = useState(value);
const [locId, setLocId] = useState(String(locationId || ""));
const [pn, setPn] = useState(pocName);
const [pp, setPp] = useState(pocPhone);
const [notes, setNotes] = useState(pocNotes);
const [pocFilledFor, setPocFilledFor] = useState("");
const isMockLocation = locId.startsWith("mock-");
const { data: apiLocationDetail } = useLocationDetail(
isMockLocation || !locId ? undefined : locId,
);
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
useEffect(() => {
if (open) {
setCode(value);
setLocId(String(locationId || ""));
setPn(pocName);
setPp(pocPhone);
setNotes(pocNotes);
setPocFilledFor(String(locationId || ""));
}
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
useEffect(() => {
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
return;
}
setPn(locationDetail.contact ?? "");
setPp(locationDetail.phone ?? "");
setPocFilledFor(locId);
}, [open, locId, locationDetail, pocFilledFor]);
const selected = sites.find((s) => String(s.id) === locId);
const handlePick = (id: string) => {
const next = sites.find((s) => String(s.id) === id);
setLocId(id);
if (next) {
setCode(extractSiteCode(next.name));
}
if (id !== pocFilledFor) {
setPocFilledFor("");
}
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Site
</DialogTitle>
<p className="sr-only">Select a site and edit its point of contact</p>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Site</FieldLabel>
<WizardFieldSelect
value={locId}
placeholder="Select site …"
icon={
<MapPin
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search site code or city…"
options={sites.map((site) => ({
key: String(site.id),
label: site.name,
}))}
onPick={handlePick}
/>
</div>
{locationDetail && locId && (
<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 className="pt-1">
<FieldLabel>Point of Contact</FieldLabel>
<p className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
Auto-filled from records — edit if outdated
</p>
<div className="space-y-2">
<input
value={pn}
onChange={(e) => setPn(e.target.value)}
placeholder="POC name"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<input
value={pp}
onChange={(e) => setPp(e.target.value)}
placeholder="POC phone"
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({
site: code || (selected ? extractSiteCode(selected.name) : ""),
location: selected ? extractLocationLabel(selected.name) : "",
locationId: locId,
pocName: pn,
pocPhone: pp,
pocNotes: notes,
});
onOpenChange(false);
}}
>
Save
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,151 @@
import { useState } from "react";
import { AlertTriangle, Check } from "lucide-react";
import { Popover } from "@mui/material";
import {
ALL_WIZARD_STATUSES,
type WizardWOStatus,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { HoverChevron } from "./hover-chevron";
import { WoTableStatusLabel } from "./wo-table-status-label";
type StatusCellProps = {
wo: WorkOrderTableRow;
onChangeStatus: (status: WizardWOStatus) => void;
onOpenSlideOver: () => void;
onPastDueAttempt?: () => void;
};
export function StatusCell({
wo,
onChangeStatus,
onOpenSlideOver,
onPastDueAttempt,
}: StatusCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
if (wo.isPastDue) {
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onPastDueAttempt?.();
setAnchorEl(e.currentTarget);
}}
title="This WO missed its service window. Reschedule to unlock status updates."
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
>
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
Past Due
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
}}
>
<div
className="flex items-start gap-2 p-3"
style={{ background: "var(--status-pending-bg)" }}
>
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
<div>
<p className="text-[11px] leading-snug text-[var(--status-pending-text)]">
This work order is past due. A new date must be scheduled before the status can be
updated.
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenSlideOver();
setAnchorEl(null);
}}
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
>
Reschedule →
</button>
</div>
</div>
</Popover>
</>
);
}
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{wo.status ? (
<WoTableStatusLabel status={wo.status} />
) : (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{ALL_WIZARD_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => {
onChangeStatus(status);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
>
<WoTableStatusLabel status={status} />
{wo.status === status && (
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
)}
</button>
))}
</Popover>
</>
);
}

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Check } from "lucide-react";
import { Popover } from "@mui/material";
import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard";
import { HoverChevron } from "./hover-chevron";
import { WoTableTypeLabel } from "./wo-table-type-label";
type TypeCellProps = {
value: WOType | "";
q?: string;
onSave: (v: WOType) => void;
};
export function TypeCell({ value, q, onSave }: TypeCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
>
{value ? (
<WoTableTypeLabel type={value} q={q} />
) : (
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
)}
<HoverChevron />
</button>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
onClick={(e) => e.stopPropagation()}
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
>
{WO_TYPES.map((t) => (
<button
key={t}
type="button"
onClick={() => {
onSave(t);
setAnchorEl(null);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
>
<WoTableTypeLabel type={t} />
{value === t && <Check size={10} className="ml-auto text-[var(--color-primary)]" />}
</button>
))}
</Popover>
</>
);
}

View file

@ -0,0 +1,57 @@
import { useState } from "react";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
import { HighlightText } from "./highlight-text";
import { HoverChevron } from "./hover-chevron";
import { VendorDialog, type VendorPatch } from "./vendor-dialog";
type VendorCellProps = {
company: string;
tech: string;
techPhone?: string;
vendors: VendorDropdownItem[];
q?: string;
onSave: (patch: VendorPatch) => void;
};
export function VendorCell({ company, tech, techPhone, vendors, q, onSave }: VendorCellProps) {
const [open, setOpen] = useState(false);
const hasAny = !!(tech || company);
return (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group/cell flex w-full items-center gap-1.5 text-left transition-opacity hover:opacity-80"
>
{hasAny ? (
<div className="min-w-0 flex-1 leading-tight">
<div className="truncate text-[13px] font-semibold text-[var(--foreground)]">
{tech ? <HighlightText text={tech} q={q} /> : <HighlightText text={company} q={q} />}
</div>
{tech && company && (
<div className="truncate text-[11px] text-[var(--muted-foreground)]">
<HighlightText text={company} q={q} />
</div>
)}
</div>
) : (
<span className="flex-1 text-[13px] text-[var(--color-text-muted)]">—</span>
)}
<HoverChevron />
</button>
<VendorDialog
open={open}
onOpenChange={setOpen}
company={company}
tech={tech}
techPhone={techPhone}
vendors={vendors}
onSave={onSave}
/>
</>
);
}

View file

@ -0,0 +1,268 @@
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import { ExternalLink, Mail, Wrench } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
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 } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
export type VendorPatch = {
company: string;
tech: string;
techPhone: string;
};
type VendorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
company: string;
tech: string;
techPhone?: string;
vendors: VendorDropdownItem[];
onSave: (patch: VendorPatch) => void;
};
type TechnicianOption = {
key: string;
label: string;
name: string;
company: string;
phone: string;
vendor?: VendorDropdownItem;
};
/** Known tech ↔ company pairs from seeded table mock data. */
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
};
function FieldLabel({ children }: { children: ReactNode }) {
return (
<label
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--color-text-secondary)" }}
>
{children}
</label>
);
}
function buildTechnicianOptions(
vendors: VendorDropdownItem[],
tech: string,
company: string,
): TechnicianOption[] {
const options = new Map<string, TechnicianOption>();
const add = (option: TechnicianOption) => {
if (!option.key) {
return;
}
options.set(option.key, option);
};
for (const vendor of vendors) {
const mockTech = MOCK_TECH_BY_COMPANY[vendor.companyName];
if (mockTech) {
add({
key: mockTech.name,
label: `${mockTech.name} · ${vendor.companyName}`,
name: mockTech.name,
company: vendor.companyName,
phone: mockTech.phone,
vendor,
});
continue;
}
add({
key: vendor.companyName,
label: vendor.companyName,
name: vendor.companyName,
company: vendor.companyName,
phone: "",
vendor,
});
}
if (tech) {
const vendor = vendors.find((v) => v.companyName === company);
add({
key: tech,
label: company && company !== tech ? `${tech} · ${company}` : tech,
name: tech,
company: company || tech,
phone: "",
vendor,
});
}
return Array.from(options.values());
}
function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
return options.find((o) => o.name === name)?.phone ?? fallback;
}
export function VendorDialog({
open,
onOpenChange,
company,
tech,
techPhone = "",
vendors,
onSave,
}: VendorDialogProps) {
const [t, setT] = useState(tech);
const [phone, setPhone] = useState(techPhone);
const [notes, setNotes] = useState("");
const technicianOptions = useMemo(
() => buildTechnicianOptions(vendors, tech, company),
[vendors, tech, company],
);
useEffect(() => {
if (open) {
setT(tech);
setPhone(techPhone || phoneForOption(tech, technicianOptions));
setNotes("");
}
}, [open, tech, techPhone, technicianOptions]);
const selected = technicianOptions.find((o) => o.name === t);
const resolvedCompany = t ? (selected?.company ?? company) : "";
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
const companyEmail = selectedVendor?.email?.trim() || "";
const handlePick = (name: string) => {
setT(name);
setPhone(phoneForOption(name, technicianOptions));
};
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
className: "w-full",
style: { borderRadius: 12, maxWidth: 480 },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
Technician
</DialogTitle>
<p className="sr-only">Search and select the technician</p>
<div className="mt-3 space-y-3">
<div>
<FieldLabel>Technician</FieldLabel>
<WizardFieldSelect
value={t}
placeholder="Search technician …"
icon={
<Wrench
size={14}
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
/>
}
searchPlaceholder="Search technician…"
options={technicianOptions.map((option) => ({
key: option.key,
label: option.label,
}))}
onPick={handlePick}
/>
</div>
{t && (
<div>
<FieldLabel>Technician Phone</FieldLabel>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className={DLG_INPUT_CLS}
style={DLG_INPUT_STYLE}
/>
</div>
)}
{t && (
<div>
<FieldLabel>Company</FieldLabel>
<div
className="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)" }}>
{resolvedCompany || "—"}
</div>
{companyEmail && <Detail icon={Mail} text={companyEmail} />}
{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>
<FieldLabel>Notes</FieldLabel>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Contact preferences, availability, etc."
rows={3}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
className="min-h-0 px-2 py-1 text-[12px]"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
className="min-h-0 px-3 py-1 text-[12px]"
onClick={() => {
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
onOpenChange(false);
}}
>
Save
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,25 @@
import { getWoTableStatusStyle } from "../wo-table-status-style";
import { HighlightText } from "./highlight-text";
type WoTableStatusLabelProps = {
status: string;
q?: string;
};
export function WoTableStatusLabel({ status, q }: WoTableStatusLabelProps) {
const style = getWoTableStatusStyle(status);
const label = status || "—";
return (
<span
className="inline-flex items-center gap-1.5 whitespace-nowrap leading-none"
style={{ fontSize: 12.5, fontWeight: 600, color: style.text }}
>
<span
className="shrink-0 rounded-full"
style={{ width: 8, height: 8, background: style.dot }}
/>
<HighlightText text={label} q={q} />
</span>
);
}

View file

@ -0,0 +1,19 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { getWoTableTypeColor } from "../wo-table-type-style";
import { HighlightText } from "./highlight-text";
type WoTableTypeLabelProps = {
type: WOType;
q?: string;
};
export function WoTableTypeLabel({ type, q }: WoTableTypeLabelProps) {
return (
<span
className="inline-flex items-center whitespace-nowrap leading-none"
style={{ fontSize: 12.5, fontWeight: 700, color: getWoTableTypeColor(type) }}
>
<HighlightText text={type} q={q} />
</span>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,337 +1,124 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
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 { 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";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order";
import {
useWorkOrderUsers,
useWorkOrdersList,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
function formatDateTime(value: string): string {
if (!value) {
return "—";
}
const normalized = value.endsWith("Z") ? value : `${value}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return value;
}
const datePart = date.toLocaleDateString("en-CA");
const timePart = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
}
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const { user } = useAuthContext();
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
const debouncedSearch = useDebounce(search, 300);
const { data: users = [] } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
sortBy,
sortDir,
status: statusFilter,
assignee: assigneeFilter,
}),
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
const pmTypes = useMemo(
() =>
isWoTableMockEnabled()
? getMockPmTypes()
: problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
const { data: users = [] } = useWorkOrderUsers();
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
if (sortDir === "desc") {
setSortBy("");
}
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
const handleStatusFilter = (status: string) => {
setStatusFilter((current) => (current === status ? "" : status));
setPage(0);
};
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Work Orders
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search..."
className="min-w-[220px]"
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
eyebrow="OPERATIONS"
title="Work Orders"
actions={
<WorkOrdersListHeader
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onNewWorkOrder={() => undefined}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
+ New Work Order
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "center",
}}
>
<Chip
label="All"
clickable
color={statusFilter === "" ? "primary" : "default"}
variant={statusFilter === "" ? "filled" : "outlined"}
onClick={() => {
setStatusFilter("");
setPage(0);
}
/>
<WorkOrdersFilterBar
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
/>
{filters.advApplied && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
sites={locations}
users={users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
onOpenSlideOver={(row) => tableData.setSelectedId(row.id)}
onOpenCompDoc={() => undefined}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
/>
{activeMediaRow && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
}}
/>
{STATUS_OPTIONS.map((status) => (
<Chip
key={status}
label={status}
clickable
color={statusFilter === status ? "primary" : "default"}
variant={statusFilter === status ? "filled" : "outlined"}
onClick={() => handleStatusFilter(status)}
/>
))}
<FormControl size="small" className="min-w-[180px]">
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
<Select
labelId="assignee-filter-label"
label="Assignee"
value={assigneeFilter}
onChange={(event) => {
setAssigneeFilter(event.target.value);
setPage(0);
}}
>
<MenuItem value="">All Assignees</MenuItem>
<MenuItem value="__unassigned">Unassigned</MenuItem>
{users.map((user) => (
<MenuItem key={String(user.id)} value={user.name}>
{user.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell
className="cursor-pointer whitespace-nowrap font-semibold"
onClick={() => handleSort("number")}
>
WO #{sortIndicator("number")}
</TableCell>
<TableCell
className="cursor-pointer font-semibold"
onClick={() => handleSort("title")}
>
Title{sortIndicator("title")}
</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Created</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Last Updated</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderTableRow) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.woNumber || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
<TableCell>{row.location}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.scheduledOn)}
</TableCell>
<TableCell>{row.tech || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
size="small"
aria-label="View work order"
onClick={() => navigate(`/workorders/${row.id}`)}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit work order"
onClick={() => navigate(`/workorders/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
</Box>
);
}

View file

@ -411,6 +411,7 @@ export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
const item = asRecord(raw);
const email = readString(item, "email", "Email");
return {
id: readId(item),
@ -418,6 +419,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
address: readString(item, "address", "Address"),
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
email: email || undefined,
};
}

View file

@ -39,6 +39,8 @@ export interface VendorDropdownItem {
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
/** Present when the dropdown/API provides a contact email; never synthesized client-side. */
email?: string;
}
export interface TaskListTemplate {