mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
Merge pull request #40 from Sea-Haven-Industries/feature/wo-table-core
feat(work-orders): add board table core and filters
This commit is contained in:
commit
33001aac74
41 changed files with 4403 additions and 344 deletions
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { Button, Popover } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
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">
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-3 pb-1 pt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-3 pb-1 pt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
End <span className="font-normal normal-case">(optional)</span>
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
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;
|
||||||
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!onOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" } }}
|
||||||
|
>
|
||||||
|
{Boolean(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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
{Boolean(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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
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 { Text } from "@/components/ui/text";
|
||||||
|
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>
|
||||||
|
<Text as="p" className="sr-only">
|
||||||
|
Select a site and edit its point of contact
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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 != null && Boolean(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 != null && <Detail icon={Phone} text={locationDetail.phone} />}
|
||||||
|
{locationDetail.contactEmail != null && (
|
||||||
|
<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 className="pt-1">
|
||||||
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mb-2"
|
||||||
|
style={{ fontSize: 11, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Auto-filled from records — edit if outdated
|
||||||
|
</Text>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
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>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.375,
|
||||||
|
color: "var(--status-pending-text)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This work order is past due. A new date must be scheduled before the status can be
|
||||||
|
updated.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
{Boolean(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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,271 @@
|
||||||
|
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 { Text } from "@/components/ui/text";
|
||||||
|
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>
|
||||||
|
<Text as="p" className="sr-only">
|
||||||
|
Search and select the technician
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{Boolean(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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(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>
|
||||||
|
{Boolean(companyEmail) && <Detail icon={Mail} text={companyEmail} />}
|
||||||
|
{selectedVendor != null && selectedVendor.address != null && (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { createElement, type ComponentProps } from "react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Droplets,
|
||||||
|
Filter,
|
||||||
|
Gauge,
|
||||||
|
Lightbulb,
|
||||||
|
Lock,
|
||||||
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
Thermometer,
|
||||||
|
Video,
|
||||||
|
Waves,
|
||||||
|
Wind,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const PM_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||||
|
"Scrubber Pit": Filter,
|
||||||
|
"Backflow Preventers": Shield,
|
||||||
|
"Domestic Backflow": ShieldCheck,
|
||||||
|
"Tank / Water Heater": Thermometer,
|
||||||
|
"Pressure Reducing Valve": Gauge,
|
||||||
|
"Pumping Systems": Waves,
|
||||||
|
"HVAC Inspection": Wind,
|
||||||
|
Electrical: Zap,
|
||||||
|
Plumbing: Wrench,
|
||||||
|
"Door / Hardware": Lock,
|
||||||
|
"Floor Drain Backing Up": Droplets,
|
||||||
|
"Parking Lot Lighting": Lightbulb,
|
||||||
|
"Mens Restroom Camera Line": Video,
|
||||||
|
"Assess Window Leaks": AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPmTypeIcon(pmType: string): LucideIcon {
|
||||||
|
return PM_TYPE_ICONS[pmType] ?? Wrench;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PmTypeIconProps = ComponentProps<LucideIcon> & { pmType: string };
|
||||||
|
|
||||||
|
export function PmTypeIcon({ pmType, ...props }: PmTypeIconProps) {
|
||||||
|
return createElement(getPmTypeIcon(pmType), props);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export const WO_TABLE_COLS = [
|
||||||
|
{ key: "grip", label: "", w: 28 },
|
||||||
|
{ key: "flag", label: "", w: 28 },
|
||||||
|
{ key: "site", label: "SITE", w: 168 },
|
||||||
|
{ key: "wo", label: "WO", w: 120 },
|
||||||
|
{ key: "type", label: "TYPE OF WO", w: 108 },
|
||||||
|
{ key: "assigned", label: "ASSIGNED TO", w: 136 },
|
||||||
|
{ key: "sched", label: "SCHEDULE ON", w: 112 },
|
||||||
|
{ key: "due", label: "DUE DATE", w: 112 },
|
||||||
|
{ key: "pm", label: "SERVICE", w: 168 },
|
||||||
|
{ key: "vendor", label: "VENDOR", w: 184 },
|
||||||
|
{ key: "appt", label: "APPT TIME", w: 128 },
|
||||||
|
{ key: "status", label: "STATUS", w: 148 },
|
||||||
|
{ key: "doc", label: "COMP DOC", w: 84 },
|
||||||
|
{ key: "actions", label: "", w: 100 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const WO_TABLE_MIN_WIDTH = 1504;
|
||||||
|
export const WO_TABLE_ROW_HEIGHT = 52;
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
|
|
||||||
|
export function WoTableHead() {
|
||||||
|
return (
|
||||||
|
<thead className="sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
{WO_TABLE_COLS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
style={{
|
||||||
|
width: col.w,
|
||||||
|
minWidth: col.w,
|
||||||
|
padding: "11px 14px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
import { Check, Flag, X } from "lucide-react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
|
|
||||||
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
|
||||||
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
|
||||||
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
|
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
|
||||||
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
|
||||||
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
|
||||||
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
|
|
||||||
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
|
|
||||||
|
type WoTableInlineRowProps = {
|
||||||
|
draft: WorkOrderTableDraft;
|
||||||
|
|
||||||
|
draftDayKey: string;
|
||||||
|
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
|
||||||
|
sites: LocationOption[];
|
||||||
|
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
|
||||||
|
pmTypes: string[];
|
||||||
|
|
||||||
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
|
||||||
|
onChange: (draft: WorkOrderTableDraft) => void;
|
||||||
|
|
||||||
|
onSave: () => void;
|
||||||
|
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineRow({
|
||||||
|
draft,
|
||||||
|
|
||||||
|
draftDayKey,
|
||||||
|
|
||||||
|
users,
|
||||||
|
|
||||||
|
sites,
|
||||||
|
|
||||||
|
vendors,
|
||||||
|
|
||||||
|
pmTypes,
|
||||||
|
|
||||||
|
allRows,
|
||||||
|
|
||||||
|
onChange,
|
||||||
|
|
||||||
|
onSave,
|
||||||
|
|
||||||
|
onCancel,
|
||||||
|
}: WoTableInlineRowProps) {
|
||||||
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
|
||||||
|
const canSave = weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn;
|
||||||
|
|
||||||
|
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||||
|
|
||||||
|
const draftRow = {
|
||||||
|
id: "draft",
|
||||||
|
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
|
||||||
|
status: draft.status,
|
||||||
|
|
||||||
|
isPastDue: false,
|
||||||
|
|
||||||
|
type: draft.type,
|
||||||
|
|
||||||
|
rescheduleCount: 0,
|
||||||
|
|
||||||
|
carriedOver: 0,
|
||||||
|
|
||||||
|
docStatus: "" as const,
|
||||||
|
|
||||||
|
mediaCount: 0,
|
||||||
|
|
||||||
|
site: draft.site,
|
||||||
|
|
||||||
|
location: draft.location,
|
||||||
|
|
||||||
|
dispatcherId: draft.dispatcherId,
|
||||||
|
|
||||||
|
scheduledOn: draft.scheduledOn,
|
||||||
|
|
||||||
|
dueDate: draft.dueDate,
|
||||||
|
|
||||||
|
pm: draft.pm,
|
||||||
|
|
||||||
|
company: draft.company,
|
||||||
|
|
||||||
|
tech: draft.tech,
|
||||||
|
|
||||||
|
targetWeek: draft.targetWeek,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
style={{
|
||||||
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
|
||||||
|
borderLeft: "2px solid var(--color-primary)",
|
||||||
|
|
||||||
|
background: "var(--color-primary-tint)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: "0 0 0 6px" }} />
|
||||||
|
|
||||||
|
<td style={{ padding: "0 2px 0 8px" }}>
|
||||||
|
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<SiteCell
|
||||||
|
value={draft.site}
|
||||||
|
location={draft.location}
|
||||||
|
locationId={draft.locationId}
|
||||||
|
pocName={draft.pocName}
|
||||||
|
pocPhone={draft.pocPhone}
|
||||||
|
pocNotes={draft.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
onSave={(p) =>
|
||||||
|
patch({
|
||||||
|
site: p.site,
|
||||||
|
|
||||||
|
location: p.location,
|
||||||
|
|
||||||
|
locationId: p.locationId,
|
||||||
|
|
||||||
|
pocName: p.pocName,
|
||||||
|
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={0}
|
||||||
|
currentId="draft"
|
||||||
|
allOrders={allRows as never[]}
|
||||||
|
onSave={(v) => patch({ woNumber: v })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<TypeCell value={draft.type} onSave={(t) => patch({ type: t })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={draft.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
highlight={!draft.dispatcherId}
|
||||||
|
onSave={(id) => {
|
||||||
|
patch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell
|
||||||
|
value={draft.scheduledOn}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={draft.targetWeek}
|
||||||
|
defaultMode={weekDefault ? "week" : "date"}
|
||||||
|
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||||
|
onSave={(date) => patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||||
|
onSaveWeek={(week) =>
|
||||||
|
patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell value={draft.dueDate} onSave={(date) => patch({ dueDate: date })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<PMTypeCell value={draft.pm} pmTypes={pmTypes} onSave={(pm) => patch({ pm })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<VendorCell
|
||||||
|
company={draft.company}
|
||||||
|
tech={draft.tech}
|
||||||
|
techPhone={draft.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={(p) => patch({ company: p.company, tech: p.tech, techPhone: p.techPhone })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<ApptTimeCell
|
||||||
|
apptTime={
|
||||||
|
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
||||||
|
}
|
||||||
|
onSave={(v) => {
|
||||||
|
const [start, end] = v.split(" – ");
|
||||||
|
|
||||||
|
patch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<StatusCell
|
||||||
|
wo={draftRow as never}
|
||||||
|
onChangeStatus={(status) => patch({ status })}
|
||||||
|
onOpenSlideOver={() => {}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 4px" }}>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
title={canSave ? "Save" : "Schedule On is required"}
|
||||||
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canSave) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background =
|
||||||
|
"var(--color-primary-tint)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
title="Cancel"
|
||||||
|
className="rounded p-1 transition-colors"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { Eye, Pencil } from "lucide-react";
|
||||||
|
|
||||||
|
type WoTableRowActionsProps = {
|
||||||
|
onView: () => void;
|
||||||
|
|
||||||
|
onEdit?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableRowActions({ onView, onEdit }: WoTableRowActionsProps) {
|
||||||
|
const mk = (
|
||||||
|
Icon: React.ElementType,
|
||||||
|
|
||||||
|
title: string,
|
||||||
|
|
||||||
|
onClick: () => void,
|
||||||
|
|
||||||
|
hoverBg: string,
|
||||||
|
|
||||||
|
hoverColor: string,
|
||||||
|
) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center transition-colors"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
|
||||||
|
height: 28,
|
||||||
|
|
||||||
|
borderRadius: 6,
|
||||||
|
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const b = e.currentTarget as HTMLButtonElement;
|
||||||
|
|
||||||
|
b.style.background = hoverBg;
|
||||||
|
|
||||||
|
b.style.color = hoverColor;
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const b = e.currentTarget as HTMLButtonElement;
|
||||||
|
|
||||||
|
b.style.background = "transparent";
|
||||||
|
|
||||||
|
b.style.color = "var(--muted-foreground)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover/row:opacity-100">
|
||||||
|
{mk(Eye, "View details", onView, "#e6f4fb", "var(--primary)")}
|
||||||
|
|
||||||
|
{onEdit != null && mk(Pencil, "Edit", onEdit, "#e6f4fb", "var(--primary)")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
import {
|
||||||
|
applySchedulePatch,
|
||||||
|
maybeAutoSchedulePatch,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
import { CompDocCell } from "./cells/comp-doc-cell";
|
||||||
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
import { FlagCell } from "./cells/flag-cell";
|
||||||
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
|
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||||
|
|
||||||
|
export type WoTableRowHandlers = {
|
||||||
|
onRowClick: (row: WorkOrderTableRow) => void;
|
||||||
|
onView: (row: WorkOrderTableRow) => void;
|
||||||
|
onEdit: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow) => void;
|
||||||
|
onPastDueAttempt?: (id: string | number) => void;
|
||||||
|
onReorder: (dragId: string | number, overId: string | number) => void;
|
||||||
|
onDragStart: (id: string | number) => void;
|
||||||
|
onDragOver: (id: string | number) => void;
|
||||||
|
onDrop: () => void;
|
||||||
|
onDragEnd: () => void;
|
||||||
|
setFlag: (id: string | number, color: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WoTableRowProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
allRows: WorkOrderTableRow[];
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
search?: string;
|
||||||
|
forceScheduleHighlight?: boolean;
|
||||||
|
flagColor?: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
isFlashing: boolean;
|
||||||
|
dragViz: DragVizState;
|
||||||
|
handlers: WoTableRowHandlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableRow({
|
||||||
|
row,
|
||||||
|
allRows,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
search,
|
||||||
|
forceScheduleHighlight,
|
||||||
|
flagColor,
|
||||||
|
isSelected,
|
||||||
|
isFlashing,
|
||||||
|
dragViz,
|
||||||
|
handlers,
|
||||||
|
}: WoTableRowProps) {
|
||||||
|
const isDragging = dragViz.dragId === row.id;
|
||||||
|
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
||||||
|
const isCanceled = (row.status as string) === "Canceled";
|
||||||
|
|
||||||
|
let firstCellShadow = "none";
|
||||||
|
if (row.type === "Emergency") {
|
||||||
|
firstCellShadow = "inset 3px 0 0 var(--destructive)";
|
||||||
|
} else if (row.isPastDue) {
|
||||||
|
firstCellShadow = "inset 3px 0 0 var(--warning)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchAndMaybeAuto = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
if (isCanceled) return;
|
||||||
|
const merged = { ...row, ...patch };
|
||||||
|
const auto = maybeAutoSchedulePatch(merged);
|
||||||
|
handlers.onPatch(row.id, { ...patch, ...auto });
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
if (isCanceled) return;
|
||||||
|
handlers.onPatch(row.id, patch);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Block mutation controls on canceled rows while keeping row/View clickable. */
|
||||||
|
const mutationCellStyle = isCanceled ? ({ pointerEvents: "none" } as const) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
id={`wo-row-${row.id}`}
|
||||||
|
draggable={!isCanceled}
|
||||||
|
title={isCanceled ? "This work order has been cancelled" : undefined}
|
||||||
|
onClick={() => handlers.onRowClick(row)}
|
||||||
|
onDragStart={() => {
|
||||||
|
if (isCanceled) return;
|
||||||
|
handlers.onDragStart(row.id);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (isCanceled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onDragOver(row.id);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
if (isCanceled) return;
|
||||||
|
e.preventDefault();
|
||||||
|
handlers.onDrop();
|
||||||
|
}}
|
||||||
|
onDragEnd={handlers.onDragEnd}
|
||||||
|
className="group/row cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined,
|
||||||
|
opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1,
|
||||||
|
background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)",
|
||||||
|
outline: isFlashing ? "2px solid var(--color-primary)" : "none",
|
||||||
|
outlineOffset: isFlashing ? "-2px" : undefined,
|
||||||
|
borderRadius: isFlashing ? 4 : undefined,
|
||||||
|
transition: "background 1.8s ease-out, outline 1.8s ease-out",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSelected && !isFlashing) {
|
||||||
|
(e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSelected && !isFlashing) {
|
||||||
|
(e.currentTarget as HTMLTableRowElement).style.background = "var(--card)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
style={{ padding: "0 0 0 6px", boxShadow: firstCellShadow, ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
cursor: isCanceled ? "default" : isDragging ? "grabbing" : "grab",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{ padding: "0 2px 0 8px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<FlagCell
|
||||||
|
color={flagColor}
|
||||||
|
disabled={isCanceled}
|
||||||
|
onChange={(c) => handlers.setFlag(row.id, c)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<SiteCell
|
||||||
|
value={row.site}
|
||||||
|
location={row.location}
|
||||||
|
locationId={row.locationId}
|
||||||
|
pocName={row.pocName}
|
||||||
|
pocPhone={row.pocPhone}
|
||||||
|
pocNotes={row.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
q={search}
|
||||||
|
onSave={(p) =>
|
||||||
|
patchRow({
|
||||||
|
site: p.site,
|
||||||
|
location: p.location,
|
||||||
|
locationId: p.locationId,
|
||||||
|
pocName: p.pocName,
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0 14px", whiteSpace: "nowrap", ...mutationCellStyle }}>
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={row.woNumber}
|
||||||
|
rescheduleCount={row.rescheduleCount}
|
||||||
|
carriedOver={row.carriedOver}
|
||||||
|
q={search}
|
||||||
|
currentId={row.id}
|
||||||
|
allOrders={allRows}
|
||||||
|
onSave={(v) => patchRow({ woNumber: v })}
|
||||||
|
onDuplicateFound={handlers.onDuplicateFound}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<TypeCell value={row.type} q={search} onSave={(t) => patchRow({ type: t })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={row.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
highlight={!row.dispatcherId}
|
||||||
|
onSave={(id, name) => patchAndMaybeAuto({ dispatcherId: id, dispatcherName: name })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DateCell
|
||||||
|
value={row.scheduledOn}
|
||||||
|
q={search}
|
||||||
|
highlight={forceScheduleHighlight || row.isPastDue}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={row.targetWeek}
|
||||||
|
onSave={(date) => patchAndMaybeAuto(applySchedulePatch(date, false))}
|
||||||
|
onSaveWeek={(week) =>
|
||||||
|
patchAndMaybeAuto({
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: week,
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DateCell value={row.dueDate} q={search} onSave={(date) => patchRow({ dueDate: date })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<PMTypeCell value={row.pm} pmTypes={pmTypes} q={search} onSave={(pm) => patchRow({ pm })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<VendorCell
|
||||||
|
company={row.company}
|
||||||
|
tech={row.tech}
|
||||||
|
techPhone={row.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
q={search}
|
||||||
|
onSave={(p) =>
|
||||||
|
patchRow({
|
||||||
|
company: p.company,
|
||||||
|
tech: p.tech,
|
||||||
|
techPhone: p.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ApptTimeCell apptTime={row.apptTime} onSave={(v) => patchRow({ apptTime: v })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<StatusCell
|
||||||
|
wo={row}
|
||||||
|
onChangeStatus={(status) => patchRow({ status })}
|
||||||
|
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
||||||
|
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CompDocCell
|
||||||
|
status={row.docStatus}
|
||||||
|
woType={row.type}
|
||||||
|
mediaCount={row.mediaCount}
|
||||||
|
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
||||||
|
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<WoTableRowActions
|
||||||
|
onView={() => handlers.onView(row)}
|
||||||
|
onEdit={isCanceled ? undefined : () => handlers.onEdit(row)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronRight, Plus } from "lucide-react";
|
||||||
|
import { WO_TABLE_COLS } from "./wo-table-cols";
|
||||||
|
|
||||||
|
type WoTableSectionHeaderProps = {
|
||||||
|
variant: "unscheduled" | "day";
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
isToday?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
onAddWo: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableSectionHeader({
|
||||||
|
variant,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
isToday,
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
onAddWo,
|
||||||
|
}: WoTableSectionHeaderProps) {
|
||||||
|
const isUnscheduled = variant === "unscheduled";
|
||||||
|
|
||||||
|
if (isUnscheduled) {
|
||||||
|
return (
|
||||||
|
<tr style={{ background: "rgba(217, 119, 6, 0.08)" }}>
|
||||||
|
<td
|
||||||
|
colSpan={WO_TABLE_COLS.length}
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 14px",
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: "inset 3px 0 0 var(--warning)",
|
||||||
|
borderTop: "1px solid #FDE68A",
|
||||||
|
borderBottom: "1px solid #FDE68A",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<AlertTriangle size={14} className="shrink-0" style={{ color: "var(--warning)" }} />
|
||||||
|
<span
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
color: "var(--warning)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
{count} work order{count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddWo();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add WO
|
||||||
|
</button>
|
||||||
|
<span className="ml-auto flex items-center" style={{ color: "var(--warning)" }}>
|
||||||
|
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={{ background: "#eef1f6" }}>
|
||||||
|
<td
|
||||||
|
colSpan={WO_TABLE_COLS.length}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 14px",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 500,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count} WO{count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{Boolean(isToday) && (
|
||||||
|
<span
|
||||||
|
className="uppercase"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#ffffff",
|
||||||
|
background: "var(--success)",
|
||||||
|
borderRadius: 9999,
|
||||||
|
padding: "1px 5px",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 transition-opacity hover:opacity-70"
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--primary)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAddWo();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add WO
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { StatusVariant } from "@/components/domain/status-badge";
|
||||||
|
import { resolveStatusVariant } from "@/components/domain/status-badge-utils";
|
||||||
|
|
||||||
|
export type WoTableStatusStyle = { text: string; dot: string };
|
||||||
|
|
||||||
|
const STATUS_TABLE_STYLE: Record<StatusVariant, WoTableStatusStyle> = {
|
||||||
|
incomplete: { text: "var(--status-incomplete-text)", dot: "#9CA3AF" },
|
||||||
|
pending: { text: "var(--status-pending-text)", dot: "#D97706" },
|
||||||
|
scheduled: { text: "var(--status-scheduled-text)", dot: "#3B82F6" },
|
||||||
|
inroute: { text: "var(--status-inroute-text)", dot: "#0EA5E9" },
|
||||||
|
onsite: { text: "var(--status-onsite-text)", dot: "#10B981" },
|
||||||
|
inprogress: { text: "var(--status-inprogress-text)", dot: "#2563EB" },
|
||||||
|
completed: { text: "var(--status-completed-text)", dot: "#16A34A" },
|
||||||
|
rescheduled: { text: "var(--status-rescheduled-text)", dot: "#F59E0B" },
|
||||||
|
canceled: { text: "var(--status-canceled-text)", dot: "#9CA3AF" },
|
||||||
|
pendingquote: { text: "var(--status-pendingquote-text)", dot: "#9333EA" },
|
||||||
|
pastdue: { text: "var(--status-pastdue-text)", dot: "#BE123C" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWoTableStatusStyle(status: string): WoTableStatusStyle {
|
||||||
|
const variant = resolveStatusVariant(status) ?? "incomplete";
|
||||||
|
return STATUS_TABLE_STYLE[variant];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
export const WO_TABLE_TYPE_TEXT: Record<WOType, string> = {
|
||||||
|
Emergency: "var(--destructive)",
|
||||||
|
Reactive: "#e0701a",
|
||||||
|
PM: "var(--primary)",
|
||||||
|
"Add-On": "#7c3aed",
|
||||||
|
Overdue: "#9F1239",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWoTableTypeColor(type: WOType | ""): string {
|
||||||
|
if (!type) return "var(--color-text-muted)";
|
||||||
|
return WO_TABLE_TYPE_TEXT[type];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import type { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
|
|
||||||
|
export type ReturnTypeOfUseWorkOrdersTableData = ReturnType<typeof useWorkOrdersTableData>;
|
||||||
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { Fragment, useMemo, useState } from "react";
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { SearchX } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { WoTableHead } from "./wo-table-head";
|
||||||
|
import { WoTableInlineRow } from "./wo-table-inline-row";
|
||||||
|
import { WoTableRow } from "./wo-table-row";
|
||||||
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
||||||
|
|
||||||
|
const CARD_STYLE: React.CSSProperties = {
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type WoTableProps = {
|
||||||
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
/** Omit until completion-doc slice mounts CompDocDialog. */
|
||||||
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
|
onClearSearch?: () => void;
|
||||||
|
onOpenAdvancedFilters?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function WoTableShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
||||||
|
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WoTableEmptyState({
|
||||||
|
message,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col items-center justify-center text-center"
|
||||||
|
style={{ gap: 14, padding: 48 }}
|
||||||
|
>
|
||||||
|
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{Boolean(onAction) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className="transition-opacity hover:opacity-80"
|
||||||
|
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WoTable({
|
||||||
|
tableData,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
onClearSearch,
|
||||||
|
onOpenAdvancedFilters,
|
||||||
|
}: WoTableProps) {
|
||||||
|
const [scheduleHighlightId, setScheduleHighlightId] = useState<string | number | null>(null);
|
||||||
|
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
||||||
|
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||||
|
tableData.cancelDraft();
|
||||||
|
});
|
||||||
|
const { dragState: dragStateRef, setDragViz, reorderRows, endDrag, setFlag } = tableData;
|
||||||
|
|
||||||
|
const handlers = useMemo(
|
||||||
|
() => ({
|
||||||
|
onRowClick: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onView: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onEdit: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, true);
|
||||||
|
},
|
||||||
|
onOpenCompDoc: onOpenCompDoc
|
||||||
|
? (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenCompDoc(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenMedia(row);
|
||||||
|
},
|
||||||
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
patchField(id, patch);
|
||||||
|
},
|
||||||
|
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(existing.id);
|
||||||
|
onOpenSlideOver(existing, false);
|
||||||
|
},
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||||
|
onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id),
|
||||||
|
onReorder: reorderRows,
|
||||||
|
onDragStart: (id: string | number) => {
|
||||||
|
dragStateRef.current = { dragId: id, overId: null };
|
||||||
|
setDragViz({ dragId: id, overId: null });
|
||||||
|
},
|
||||||
|
onDragOver: (id: string | number) => {
|
||||||
|
const did = dragStateRef.current.dragId;
|
||||||
|
if (did && did !== id && dragStateRef.current.overId !== id) {
|
||||||
|
dragStateRef.current.overId = id;
|
||||||
|
setDragViz((v) => ({ ...v, overId: id }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
const { dragId, overId } = dragStateRef.current;
|
||||||
|
if (dragId && overId) {
|
||||||
|
reorderRows(dragId, overId);
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
setFlag,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
tableData,
|
||||||
|
dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
setFlag,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
patchField,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
|
<WoTableRow
|
||||||
|
key={String(row.id)}
|
||||||
|
row={row}
|
||||||
|
allRows={tableData.rows}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
search={tableData.search}
|
||||||
|
forceScheduleHighlight={forceScheduleHighlight || scheduleHighlightId === row.id}
|
||||||
|
flagColor={tableData.flags[String(row.id)]}
|
||||||
|
isSelected={tableData.selectedId === row.id}
|
||||||
|
isFlashing={tableData.flashId === row.id}
|
||||||
|
dragViz={tableData.dragViz}
|
||||||
|
handlers={handlers}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSaveDraft = () => {
|
||||||
|
const d = tableData.draft;
|
||||||
|
const assignee = users.find((u) => String(u.id) === d.dispatcherId);
|
||||||
|
const formValues = mapWizardDraftToFormValues({
|
||||||
|
locationId: String(d.locationId || ""),
|
||||||
|
locationName: d.location || d.site,
|
||||||
|
woNumber: d.woNumber,
|
||||||
|
type: d.type,
|
||||||
|
assigneeId: d.dispatcherId,
|
||||||
|
assigneeName: assignee?.name ?? "",
|
||||||
|
scheduledOn: d.scheduledOn,
|
||||||
|
dueDate: d.dueDate,
|
||||||
|
pm: d.pm,
|
||||||
|
vendorId: "",
|
||||||
|
vendorName: d.company,
|
||||||
|
techPhone: d.techPhone,
|
||||||
|
vendorNotes: "",
|
||||||
|
apptStart: d.apptStart,
|
||||||
|
apptEnd: d.apptEnd,
|
||||||
|
status: d.status || "Incomplete",
|
||||||
|
pocName: d.pocName,
|
||||||
|
pocPhone: d.pocPhone,
|
||||||
|
pocNotes: d.pocNotes,
|
||||||
|
scheduleWeekOnly: d.scheduleWeekOnly,
|
||||||
|
});
|
||||||
|
createMutation.mutate(formValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tableData.isLoading) {
|
||||||
|
return (
|
||||||
|
<WoTableShell>
|
||||||
|
<div className="flex h-full items-center justify-center p-12">
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
</WoTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyAdv = tableData.advActive && tableData.advResults.length === 0;
|
||||||
|
|
||||||
|
if (emptyAdv) {
|
||||||
|
return (
|
||||||
|
<WoTableShell>
|
||||||
|
<WoTableEmptyState
|
||||||
|
message="No work orders match these filters"
|
||||||
|
actionLabel="Adjust filters"
|
||||||
|
onAction={onOpenAdvancedFilters}
|
||||||
|
/>
|
||||||
|
</WoTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableData.advActive && tableData.noSearchResults) {
|
||||||
|
return (
|
||||||
|
<WoTableShell>
|
||||||
|
<WoTableEmptyState
|
||||||
|
message="No work orders match your search"
|
||||||
|
actionLabel="Clear search"
|
||||||
|
onAction={onClearSearch}
|
||||||
|
/>
|
||||||
|
</WoTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WoTableShell>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
minWidth: WO_TABLE_MIN_WIDTH,
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WoTableHead />
|
||||||
|
<tbody>
|
||||||
|
{tableData.advActive ? (
|
||||||
|
tableData.advResults.map((row) => renderRow(row))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(tableData.unscheduled.length > 0 ||
|
||||||
|
tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && (
|
||||||
|
<>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="unscheduled"
|
||||||
|
label="Unscheduled"
|
||||||
|
count={tableData.unscheduled.length}
|
||||||
|
collapsed={!tableData.unschedOpen}
|
||||||
|
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
||||||
|
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
||||||
|
/>
|
||||||
|
{tableData.unschedOpen && (
|
||||||
|
<>
|
||||||
|
{tableData.draftDayKey === UNSCHEDULED_DAY_KEY && (
|
||||||
|
<WoTableInlineRow
|
||||||
|
draft={tableData.draft}
|
||||||
|
draftDayKey={tableData.draftDayKey}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allRows={tableData.rows}
|
||||||
|
onChange={tableData.setDraft}
|
||||||
|
onSave={handleSaveDraft}
|
||||||
|
onCancel={tableData.cancelDraft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tableData.displayGroups.map((group) => (
|
||||||
|
<Fragment key={group.key}>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="day"
|
||||||
|
label={group.label}
|
||||||
|
count={group.items.length}
|
||||||
|
isToday={group.key === tableData.todayKey}
|
||||||
|
onAddWo={() => tableData.startDayDraft(group.key)}
|
||||||
|
/>
|
||||||
|
{tableData.draftDayKey === group.key && (
|
||||||
|
<WoTableInlineRow
|
||||||
|
draft={tableData.draft}
|
||||||
|
draftDayKey={tableData.draftDayKey}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allRows={tableData.rows}
|
||||||
|
onChange={tableData.setDraft}
|
||||||
|
onSave={handleSaveDraft}
|
||||||
|
onCancel={tableData.cancelDraft}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{group.items.map((row) => renderRow(row))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</WoTableShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,25 @@
|
||||||
import { CircularProgress } from "@mui/material";
|
import { CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||||
|
|
||||||
import { SearchInput } from "@/components/ui/search-input";
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
|
|
||||||
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
import { ToolbarButton } from "@/components/ui/toolbar-button";
|
||||||
|
|
||||||
type WorkOrdersListHeaderProps = {
|
type WorkOrdersListHeaderProps = {
|
||||||
search: string;
|
search: string;
|
||||||
|
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
|
/** Omit until Advanced Search slice mounts the sheet. */
|
||||||
onOpenAdvancedFilters: () => void;
|
onOpenAdvancedFilters?: () => void;
|
||||||
|
/** Omit until wizard shell slice mounts New WO. */
|
||||||
onNewWorkOrder: () => void;
|
onNewWorkOrder?: () => void;
|
||||||
|
|
||||||
activeFilterCount: number;
|
activeFilterCount: number;
|
||||||
|
|
||||||
isFetching?: boolean;
|
isFetching?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkOrdersListHeader({
|
export function WorkOrdersListHeader({
|
||||||
search,
|
search,
|
||||||
|
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
|
||||||
onOpenAdvancedFilters,
|
onOpenAdvancedFilters,
|
||||||
|
|
||||||
onNewWorkOrder,
|
onNewWorkOrder,
|
||||||
|
|
||||||
activeFilterCount,
|
activeFilterCount,
|
||||||
|
|
||||||
isFetching = false,
|
isFetching = false,
|
||||||
}: WorkOrdersListHeaderProps) {
|
}: WorkOrdersListHeaderProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -42,6 +31,7 @@ export function WorkOrdersListHeader({
|
||||||
aria-label="Search work orders"
|
aria-label="Search work orders"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{Boolean(onOpenAdvancedFilters) && (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={<SlidersHorizontal size={13} />}
|
icon={<SlidersHorizontal size={13} />}
|
||||||
|
|
@ -52,10 +42,13 @@ export function WorkOrdersListHeader({
|
||||||
>
|
>
|
||||||
Filters
|
Filters
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(onNewWorkOrder) && (
|
||||||
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
|
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
|
||||||
New WO
|
New WO
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFetching && <CircularProgress size={14} />}
|
{isFetching && <CircularProgress size={14} />}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { MONTH_NAMES, pad2, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
const WEEKDAY_HEADER = ["S", "M", "T", "W", "T", "F", "S"];
|
||||||
|
|
||||||
|
type MiniCalendarProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (iso: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MiniCalendar({ value, onChange }: MiniCalendarProps) {
|
||||||
|
const init = /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : todayIso();
|
||||||
|
const [iy, im] = init.split("-").map(Number);
|
||||||
|
const [viewY, setViewY] = useState(iy);
|
||||||
|
const [viewM, setViewM] = useState(im);
|
||||||
|
|
||||||
|
const firstWeekday = new Date(viewY, viewM - 1, 1).getDay();
|
||||||
|
const daysInMonth = new Date(viewY, viewM, 0).getDate();
|
||||||
|
const cells: (number | null)[] = [
|
||||||
|
...Array(firstWeekday).fill(null),
|
||||||
|
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const step = (delta: number) => {
|
||||||
|
let m = viewM + delta;
|
||||||
|
let y = viewY;
|
||||||
|
if (m < 1) {
|
||||||
|
m = 12;
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
if (m > 12) {
|
||||||
|
m = 1;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
setViewM(m);
|
||||||
|
setViewY(y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const today = todayIso();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 224 }}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{MONTH_NAMES[viewM - 1]} {viewY}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||||
|
{WEEKDAY_HEADER.map((d, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="py-0.5 text-center text-[10px] font-medium"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{cells.map((day, i) => {
|
||||||
|
if (day === null) {
|
||||||
|
return <span key={i} />;
|
||||||
|
}
|
||||||
|
const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
|
||||||
|
const selected = iso === value;
|
||||||
|
const isToday = iso === today;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(iso)}
|
||||||
|
className="text-center text-[12px] transition-colors hover:bg-[var(--color-primary-tint)]"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 4,
|
||||||
|
fontWeight: selected || isToday ? 600 : 400,
|
||||||
|
background: selected ? "var(--color-primary)" : "transparent",
|
||||||
|
color: selected
|
||||||
|
? "var(--color-primary-foreground)"
|
||||||
|
: isToday
|
||||||
|
? "var(--color-primary)"
|
||||||
|
: "var(--color-text-primary)",
|
||||||
|
border:
|
||||||
|
isToday && !selected
|
||||||
|
? "1px solid color-mix(in srgb, var(--color-primary) 50%, transparent)"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import {
|
||||||
|
MONTH_NAMES,
|
||||||
|
mondayOf,
|
||||||
|
todayIso,
|
||||||
|
weekRangeLabel,
|
||||||
|
weeksOfMonth,
|
||||||
|
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
type WeekPickerProps = {
|
||||||
|
selected: string;
|
||||||
|
onPick: (mondayIso: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WeekPicker({ selected, onPick }: WeekPickerProps) {
|
||||||
|
const init = /^\d{4}-\d{2}-\d{2}$/.test(selected) ? selected : todayIso();
|
||||||
|
const [iy, im] = init.split("-").map(Number);
|
||||||
|
const [viewY, setViewY] = useState(iy);
|
||||||
|
const [viewM, setViewM] = useState(im);
|
||||||
|
|
||||||
|
const step = (delta: number) => {
|
||||||
|
let m = viewM + delta;
|
||||||
|
let y = viewY;
|
||||||
|
if (m < 1) {
|
||||||
|
m = 12;
|
||||||
|
y -= 1;
|
||||||
|
}
|
||||||
|
if (m > 12) {
|
||||||
|
m = 1;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
setViewM(m);
|
||||||
|
setViewY(y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const weeks = weeksOfMonth(viewY, viewM);
|
||||||
|
const thisMonday = mondayOf(todayIso());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 240 }}>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
aria-label="Previous month"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[12px] font-medium" style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{MONTH_NAMES[viewM - 1]} {viewY}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
className="rounded p-1 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
aria-label="Next month"
|
||||||
|
>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: 200, overflowY: "auto" }} className="flex flex-col gap-1.5">
|
||||||
|
{weeks.map((mon) => {
|
||||||
|
const isSel = Boolean(selected) && selected === mon;
|
||||||
|
const isThis = mon === thisMonday;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mon}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(mon)}
|
||||||
|
className="flex items-center transition-colors"
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: isSel ? 600 : 400,
|
||||||
|
padding: "0 12px",
|
||||||
|
background: isSel ? "var(--color-primary-tint)" : "transparent",
|
||||||
|
border: `1px solid ${isSel ? "var(--color-primary)" : "var(--color-border)"}`,
|
||||||
|
color: isSel ? "var(--color-primary)" : "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{weekRangeLabel(mon)}
|
||||||
|
{isThis && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
marginLeft: "auto",
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: isSel ? "var(--color-primary)" : "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
This week
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
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 { Text } from "@/components/ui/text";
|
||||||
|
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 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="px-2 py-2 text-center"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
No matches
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
filtered.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick(o.key);
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
{o.left != null && <span className="flex shrink-0 items-center">{o.left}</span>}
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-left text-[12px]"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</span>
|
||||||
|
{value === o.key && (
|
||||||
|
<Check size={10} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export const DLG_INPUT_CLS =
|
||||||
|
"w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary";
|
||||||
|
|
||||||
|
export const DLG_INPUT_STYLE: React.CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import type { CSSProperties, ElementType, ReactNode } from "react";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
export function StepCircle({ n, done, active }: { n: number; done: boolean; active: boolean }) {
|
||||||
|
const base: React.CSSProperties = {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center"
|
||||||
|
style={{ ...base, background: "var(--color-success)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center"
|
||||||
|
style={{ ...base, background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center justify-center"
|
||||||
|
style={{
|
||||||
|
...base,
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const WIZ_LABEL_STYLE: CSSProperties = {
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
};
|
||||||
|
|
||||||
|
function WizOptionalTag() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="shrink-0 leading-none"
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 400,
|
||||||
|
letterSpacing: 0,
|
||||||
|
textTransform: "none",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
optional
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WizLabel({
|
||||||
|
children,
|
||||||
|
optional,
|
||||||
|
required,
|
||||||
|
leading,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
optional?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
leading?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="mb-1.5 inline-flex w-full flex-nowrap items-center gap-1.5"
|
||||||
|
style={WIZ_LABEL_STYLE}
|
||||||
|
>
|
||||||
|
{leading != null && <span className="flex shrink-0 items-center">{leading}</span>}
|
||||||
|
<span className="shrink-0">{children}</span>
|
||||||
|
{Boolean(required) && <span style={{ color: "var(--destructive)" }}>*</span>}
|
||||||
|
{Boolean(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>
|
||||||
|
{Boolean(optional) && <WizOptionalTag />}
|
||||||
|
</span>
|
||||||
|
{Boolean(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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal file
58
src/app/(protected)/workorders/_hooks/use-table-sort.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import type {
|
||||||
|
SortDirection,
|
||||||
|
WorkOrdersSortableColumn,
|
||||||
|
} from "@/domain/work-orders/types/work-order-list";
|
||||||
|
|
||||||
|
type TableSortState = {
|
||||||
|
column: WorkOrdersSortableColumn | "";
|
||||||
|
direction: SortDirection;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTableSort(initialColumn: WorkOrdersSortableColumn | "" = "") {
|
||||||
|
const [sortBy, setSortBy] = useState<WorkOrdersSortableColumn | "">(initialColumn);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDirection>("");
|
||||||
|
|
||||||
|
const handleSort = useCallback((column: WorkOrdersSortableColumn | string) => {
|
||||||
|
const col = column as WorkOrdersSortableColumn;
|
||||||
|
setSortBy((currentColumn) => {
|
||||||
|
if (currentColumn !== col) {
|
||||||
|
setSortDir("asc");
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortDir((currentDir) => {
|
||||||
|
if (currentDir === "asc") {
|
||||||
|
return "desc";
|
||||||
|
}
|
||||||
|
if (currentDir === "desc") {
|
||||||
|
setSortBy("");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "asc";
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentColumn;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sortIndicator = useCallback(
|
||||||
|
(column: string) => {
|
||||||
|
if (sortBy !== column) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return sortDir === "desc" ? " ↓" : sortDir === "asc" ? " ↑" : "";
|
||||||
|
},
|
||||||
|
[sortBy, sortDir],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sort: TableSortState = { column: sortBy, direction: sortDir };
|
||||||
|
|
||||||
|
return {
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
sort,
|
||||||
|
handleSort,
|
||||||
|
sortIndicator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { useWorkOrdersList } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import {
|
||||||
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNSCHEDULED_DAY_KEY,
|
||||||
|
type WorkOrderTableDraft,
|
||||||
|
type WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import {
|
||||||
|
buildDayGroups,
|
||||||
|
enrichTableRow,
|
||||||
|
filterByBarAndSearch,
|
||||||
|
matchesAdvancedFilters,
|
||||||
|
splitScheduledUnscheduled,
|
||||||
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
|
||||||
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
export type DragVizState = {
|
||||||
|
dragId: string | number | null;
|
||||||
|
overId: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
|
const { data, isLoading, isFetching } = useWorkOrdersList(filters.listParams);
|
||||||
|
const { data: scopeData } = useWorkOrdersList(filters.scopeListParams);
|
||||||
|
|
||||||
|
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||||
|
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||||
|
const [rowOrder, setRowOrder] = useState<string[] | null>(null);
|
||||||
|
const [flags, setFlagsState] = useState<Record<string, string>>({});
|
||||||
|
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||||
|
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||||
|
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||||
|
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
||||||
|
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
||||||
|
|
||||||
|
const apiRows = useMemo(
|
||||||
|
() => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||||
|
[data?.items, patches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||||
|
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||||
|
let merged = [...apiRows, ...extra.map(enrichTableRow)];
|
||||||
|
if (rowOrder?.length) {
|
||||||
|
const orderMap = new Map(rowOrder.map((id, idx) => [id, idx]));
|
||||||
|
merged = [...merged].sort((a, b) => {
|
||||||
|
const ai = orderMap.get(String(a.id)) ?? 9999;
|
||||||
|
const bi = orderMap.get(String(b.id)) ?? 9999;
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}, [apiRows, localRows, rowOrder]);
|
||||||
|
|
||||||
|
const advActive = filters.advApplied !== null;
|
||||||
|
const advResults = useMemo(() => {
|
||||||
|
if (!filters.advApplied) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return displayRows.filter((row) => matchesAdvancedFilters(row, filters.advApplied!));
|
||||||
|
}, [displayRows, filters.advApplied]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (advActive) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return filterByBarAndSearch(displayRows, filters.barFilters, filters.search);
|
||||||
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
|
const { scheduled, unscheduled } = useMemo(() => splitScheduledUnscheduled(filtered), [filtered]);
|
||||||
|
|
||||||
|
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||||
|
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||||
|
|
||||||
|
const searchActive = filters.search.trim() !== "";
|
||||||
|
const noSearchResults = filtered.length === 0 && searchActive;
|
||||||
|
const isEmptyWeek = scheduled.length === 0 && !searchActive;
|
||||||
|
const displayGroups = isEmptyWeek ? weekSkeleton : dayGroups;
|
||||||
|
|
||||||
|
const todayKey =
|
||||||
|
filters.weekMonday === currentWeekMonday()
|
||||||
|
? deriveDayFields(new Date().toISOString().slice(0, 10)).dayGroup
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedRow = useMemo(
|
||||||
|
() => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null,
|
||||||
|
[displayRows, selectedId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFlag = useCallback((id: string | number, color: string | null) => {
|
||||||
|
setFlagsState((prev) => {
|
||||||
|
const key = String(id);
|
||||||
|
if (!color) {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: color };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const patchRow = useCallback((id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
const key = String(id);
|
||||||
|
setPatches((prev) => {
|
||||||
|
const merged = { ...prev[key], ...patch };
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
const dayFields = deriveDayFields(patch.scheduledOn);
|
||||||
|
merged.dayGroup = dayFields.dayGroup;
|
||||||
|
merged.dayLabel = dayFields.dayLabel;
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: merged };
|
||||||
|
});
|
||||||
|
setLocalRows((prev) =>
|
||||||
|
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reorderRows = useCallback(
|
||||||
|
(dragId: string | number, overId: string | number) => {
|
||||||
|
const dragKey = String(dragId);
|
||||||
|
const overKey = String(overId);
|
||||||
|
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||||
|
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||||
|
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ids = displayRows.map((r) => String(r.id));
|
||||||
|
const dragIdx = ids.indexOf(dragKey);
|
||||||
|
const overIdx = ids.indexOf(overKey);
|
||||||
|
if (dragIdx < 0 || overIdx < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = [...ids];
|
||||||
|
next.splice(dragIdx, 1);
|
||||||
|
next.splice(overIdx, 0, dragKey);
|
||||||
|
setRowOrder(next);
|
||||||
|
},
|
||||||
|
[displayRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
dragState.current = { dragId: null, overId: null };
|
||||||
|
setDragViz({ dragId: null, overId: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDayDraft = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
setDraftDayKey(key);
|
||||||
|
const scheduledOn =
|
||||||
|
key === UNSCHEDULED_DAY_KEY
|
||||||
|
? ""
|
||||||
|
: (() => {
|
||||||
|
const [mm, dd] = key.split("/").map(Number);
|
||||||
|
const year = filters.weekMonday.slice(0, 4);
|
||||||
|
return `${year}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
||||||
|
})();
|
||||||
|
setDraft({
|
||||||
|
...EMPTY_TABLE_DRAFT,
|
||||||
|
scheduledOn,
|
||||||
|
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
||||||
|
targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[filters.weekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDraft = useCallback(() => {
|
||||||
|
setDraftDayKey(null);
|
||||||
|
setDraft(EMPTY_TABLE_DRAFT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addRow = useCallback((row: WorkOrderTableRow) => {
|
||||||
|
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
||||||
|
setFlashId(row.id);
|
||||||
|
setTimeout(() => setFlashId(null), 2000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: displayRows,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
advActive,
|
||||||
|
advResults,
|
||||||
|
unscheduled,
|
||||||
|
displayGroups,
|
||||||
|
searchActive,
|
||||||
|
noSearchResults,
|
||||||
|
isEmptyWeek,
|
||||||
|
todayKey,
|
||||||
|
flags,
|
||||||
|
setFlag,
|
||||||
|
draftDayKey,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
unschedOpen,
|
||||||
|
setUnschedOpen,
|
||||||
|
selectedId,
|
||||||
|
setSelectedId,
|
||||||
|
selectedRow,
|
||||||
|
flashId,
|
||||||
|
dragViz,
|
||||||
|
setDragViz,
|
||||||
|
dragState,
|
||||||
|
patchRow,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
startDayDraft,
|
||||||
|
cancelDraft,
|
||||||
|
addRow,
|
||||||
|
scopeTotalCount: scopeData?.totalCount ?? 0,
|
||||||
|
totalCount: data?.totalCount ?? displayRows.length,
|
||||||
|
search: filters.search,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,337 +1,125 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
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 {
|
import {
|
||||||
Alert,
|
useWorkOrderLocations,
|
||||||
Box,
|
useWorkOrderVendors,
|
||||||
Button,
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
Chip,
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
CircularProgress,
|
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||||
FormControl,
|
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||||
IconButton,
|
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
InputLabel,
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
MenuItem,
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
Paper,
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkOrdersListPage() {
|
export default function WorkOrdersListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [search, setSearch] = useState("");
|
const { user } = useAuthContext();
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||||
const [page, setPage] = useState(0);
|
const filters = useWorkOrdersListFilters(user?.id);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
const [sortBy, setSortBy] = useState("");
|
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
|
||||||
|
|
||||||
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(
|
const pmTypes = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
search: debouncedSearch,
|
isWoTableMockEnabled()
|
||||||
page: page + 1,
|
? getMockPmTypes()
|
||||||
pageSize,
|
: problemOptions.map((option) => option.value).filter(Boolean),
|
||||||
sortBy,
|
[problemOptions],
|
||||||
sortDir,
|
|
||||||
status: statusFilter,
|
|
||||||
assignee: assigneeFilter,
|
|
||||||
}),
|
|
||||||
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
|
const totalCount = tableData.totalCount;
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
const scopeTotalCount = tableData.scopeTotalCount;
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const activeMediaRow = useMemo(() => {
|
||||||
const totalCount = data?.totalCount ?? 0;
|
if (!mediaRow) return null;
|
||||||
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
const handleSort = (column: string) => {
|
}, [mediaRow, tableData.rows]);
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
<Stack
|
<PageHeader
|
||||||
direction={{ xs: "column", md: "row" }}
|
surface
|
||||||
spacing={2}
|
eyebrow="OPERATIONS"
|
||||||
sx={{
|
title="Work Orders"
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
actions={
|
||||||
justifyContent: "space-between",
|
<WorkOrdersListHeader
|
||||||
}}
|
search={filters.search}
|
||||||
>
|
onSearchChange={filters.handleSearchChange}
|
||||||
<Stack
|
activeFilterCount={filters.activeFilterCount}
|
||||||
direction="row"
|
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||||
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
|
<WorkOrdersFilterBar
|
||||||
direction={{ xs: "column", sm: "row" }}
|
weekMonday={filters.weekMonday}
|
||||||
spacing={2}
|
onWeekChange={filters.setWeekMonday}
|
||||||
sx={{
|
dispatcherIds={filters.dispatcherIds}
|
||||||
alignItems: "center",
|
onDispatcherChange={filters.setDispatcherIds}
|
||||||
}}
|
typeFilter={filters.typeFilter}
|
||||||
>
|
onTypeChange={filters.setTypeFilter}
|
||||||
<TextField
|
users={users}
|
||||||
size="small"
|
currentUserId={user?.id}
|
||||||
value={search}
|
filteredCount={totalCount}
|
||||||
onChange={(event) => {
|
scopeTotalCount={scopeTotalCount}
|
||||||
setSearch(event.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
placeholder="Search..."
|
|
||||||
className="min-w-[220px]"
|
|
||||||
/>
|
/>
|
||||||
<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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{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]">
|
{filters.advApplied != null && (
|
||||||
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
<Select
|
applied={filters.advApplied}
|
||||||
labelId="assignee-filter-label"
|
totalCount={totalCount}
|
||||||
label="Assignee"
|
sites={locations}
|
||||||
value={assigneeFilter}
|
users={users}
|
||||||
onChange={(event) => {
|
onClearChip={filters.clearAdvancedFilterChip}
|
||||||
setAssigneeFilter(event.target.value);
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
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>
|
|
||||||
{Boolean(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]}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WoTable
|
||||||
|
tableData={tableData}
|
||||||
|
users={users}
|
||||||
|
sites={locations}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
onOpenSlideOver={(row, edit) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||||
|
}}
|
||||||
|
onOpenMedia={setMediaRow}
|
||||||
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{activeMediaRow != null && (
|
||||||
|
<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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,7 @@ export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
|
||||||
|
|
||||||
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
const email = readString(item, "email", "Email");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: readId(item),
|
id: readId(item),
|
||||||
|
|
@ -420,6 +421,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
|
||||||
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
|
||||||
address: readString(item, "address", "Address"),
|
address: readString(item, "address", "Address"),
|
||||||
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
|
||||||
|
email: email || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ export interface VendorDropdownItem {
|
||||||
tradeSpecialties: string;
|
tradeSpecialties: string;
|
||||||
address: string;
|
address: string;
|
||||||
distanceMiles: number | null;
|
distanceMiles: number | null;
|
||||||
|
/** Present when the dropdown/API provides a contact email; never synthesized client-side. */
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskListTemplate {
|
export interface TaskListTemplate {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { fireEvent, screen } 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";
|
||||||
|
import WorkOrdersListPage from "@/app/(protected)/workorders/index";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const navigate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("react-router", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-router")>("react-router");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", () => ({
|
||||||
|
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-list-filters", () => ({
|
||||||
|
useWorkOrdersListFilters: () => ({
|
||||||
|
search: "",
|
||||||
|
handleSearchChange: vi.fn(),
|
||||||
|
setAdvOpen: vi.fn(),
|
||||||
|
activeFilterCount: 0,
|
||||||
|
advApplied: null,
|
||||||
|
clearAdvancedFilterChip: vi.fn(),
|
||||||
|
clearAdvancedFilters: vi.fn(),
|
||||||
|
weekMonday: "2026-07-20",
|
||||||
|
setWeekMonday: vi.fn(),
|
||||||
|
dispatcherIds: new Set<string>(),
|
||||||
|
setDispatcherIds: vi.fn(),
|
||||||
|
typeFilter: "All",
|
||||||
|
setTypeFilter: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar", () => ({
|
||||||
|
WorkOrdersFilterBar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner",
|
||||||
|
() => ({
|
||||||
|
WorkOrdersAdvancedFiltersBanner: () => null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/media/media-upload-modal", () => ({
|
||||||
|
MediaUploadModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const canceledRow: WorkOrderTableRow = {
|
||||||
|
id: 77,
|
||||||
|
woNumber: "WO-77",
|
||||||
|
site: "CHI-1042",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Test Dispatcher",
|
||||||
|
scheduledOn: "2026-07-20",
|
||||||
|
targetWeek: "2026-07-20",
|
||||||
|
dayGroup: "2026-07-20",
|
||||||
|
dayLabel: "Mon",
|
||||||
|
location: "Chicago Loop",
|
||||||
|
locationId: "mock-loc-1",
|
||||||
|
pm: "HVAC — Quarterly PM",
|
||||||
|
company: "",
|
||||||
|
tech: "",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Canceled" as WorkOrderTableRow["status"],
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_hooks/use-work-orders-table-data", () => ({
|
||||||
|
useWorkOrdersTableData: () => ({
|
||||||
|
rows: [canceledRow],
|
||||||
|
totalCount: 1,
|
||||||
|
scopeTotalCount: 1,
|
||||||
|
isFetching: false,
|
||||||
|
isLoading: false,
|
||||||
|
setSelectedId: vi.fn(),
|
||||||
|
patchRow: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-table-mutations", () => ({
|
||||||
|
useWorkOrderTableMutations: () => ({ patchField: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||||
|
useWorkOrderUsers: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderLocations: () => ({ data: [] }),
|
||||||
|
useWorkOrderVendors: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||||
|
useDropdownOptionsByCategory: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/mocks/is-wo-table-mock-enabled", () => ({
|
||||||
|
isWoTableMockEnabled: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/list/table/wo-table", () => ({
|
||||||
|
WoTable: ({
|
||||||
|
onOpenSlideOver,
|
||||||
|
}: {
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
}) => (
|
||||||
|
<button type="button" onClick={() => onOpenSlideOver(canceledRow, false)}>
|
||||||
|
View canceled row
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("WorkOrdersListHeader slice affordances", () => {
|
||||||
|
it("hides New WO and Advanced Filters when callbacks are omitted", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<WorkOrdersListHeader search="" onSearchChange={vi.fn()} activeFilterCount={0} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Search work orders")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows New WO and Advanced Filters when callbacks are provided", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<WorkOrdersListHeader
|
||||||
|
search=""
|
||||||
|
onSearchChange={vi.fn()}
|
||||||
|
onNewWorkOrder={vi.fn()}
|
||||||
|
onOpenAdvancedFilters={vi.fn()}
|
||||||
|
activeFilterCount={2}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /new wo/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Advanced Filters")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
|
it("does not render New WO or Advanced Filters, and View navigates to detail", () => {
|
||||||
|
navigate.mockReset();
|
||||||
|
|
||||||
|
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /new wo/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Advanced Filters")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /view doc|draft/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/workorders/77");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CompDocCell slice affordances", () => {
|
||||||
|
it("hides View Doc / Draft when onOpen is omitted", () => {
|
||||||
|
const { container } = renderWithProviders(
|
||||||
|
<CompDocCell status="No" woType="PM" onOpenMedia={vi.fn()} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /view doc|draft|n\/n/i })).not.toBeInTheDocument();
|
||||||
|
expect(container.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows View Doc when onOpen is provided", () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<CompDocCell status="Yes" woType="PM" onOpen={vi.fn()} onOpenMedia={vi.fn()} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /view doc/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue