mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 11:39:00 +00:00
feat(work-orders): add board table cells and dialogs
This commit is contained in:
parent
1cb8362d76
commit
cbaae41f98
18 changed files with 1883 additions and 0 deletions
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { Button, Popover } from "@mui/material";
|
||||||
|
import {
|
||||||
|
calcDuration,
|
||||||
|
fmt12,
|
||||||
|
formatApptRange,
|
||||||
|
formatApptTimeValue,
|
||||||
|
parseApptTime,
|
||||||
|
TIME_SLOTS,
|
||||||
|
} from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
|
type ApptTimeCellProps = {
|
||||||
|
apptTime: string;
|
||||||
|
onSave: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApptTimeCell({ apptTime, onSave }: ApptTimeCellProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [start, setStart] = useState(() => parseApptTime(apptTime).start);
|
||||||
|
const [end, setEnd] = useState(() => parseApptTime(apptTime).end);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const p = parseApptTime(apptTime);
|
||||||
|
setStart(p.start);
|
||||||
|
setEnd(p.end);
|
||||||
|
}, [apptTime]);
|
||||||
|
|
||||||
|
const parsed = parseApptTime(apptTime);
|
||||||
|
const display = parsed.start ? formatApptRange(parsed.start, parsed.end) : apptTime;
|
||||||
|
|
||||||
|
const slotStyle = (active: boolean): React.CSSProperties => ({
|
||||||
|
background: active ? "var(--color-primary-tint)" : "transparent",
|
||||||
|
color: active ? "var(--color-primary)" : "var(--color-text-primary)",
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
const done = () => {
|
||||||
|
onSave(formatApptTimeValue(start, end));
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
className="group/cell flex w-full items-center gap-1.5 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<Clock size={11} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
{display && display !== "—" ? (
|
||||||
|
<span className="whitespace-nowrap text-[13px] text-[var(--color-text-secondary)]">
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-[var(--color-text-muted)]">Select …</span>
|
||||||
|
)}
|
||||||
|
<HoverChevron />
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 border-b border-border">
|
||||||
|
<div className="border-r border-border">
|
||||||
|
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Start
|
||||||
|
</p>
|
||||||
|
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||||
|
{TIME_SLOTS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStart(t);
|
||||||
|
if (end && end <= t) {
|
||||||
|
setEnd("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={slotStyle(start === t)}
|
||||||
|
>
|
||||||
|
{fmt12(t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
End <span className="font-normal normal-case">(optional)</span>
|
||||||
|
</p>
|
||||||
|
<div className="max-h-[220px] overflow-y-auto px-1.5 pb-1.5">
|
||||||
|
{TIME_SLOTS.filter((t) => !start || t > start).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEnd(t)}
|
||||||
|
className="w-full rounded px-2 py-1 text-left text-xs transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
style={slotStyle(end === t)}
|
||||||
|
>
|
||||||
|
{fmt12(t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"}
|
||||||
|
</span>
|
||||||
|
<Button size="small" variant="contained" onClick={done} disabled={!start}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,67 @@
|
||||||
|
import { Ban, FileText, Image as ImageIcon } from "lucide-react";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompDocCellProps = {
|
||||||
|
status: CompletionDocStatus;
|
||||||
|
woType: WOType | "";
|
||||||
|
mediaCount?: number;
|
||||||
|
onOpen: () => void;
|
||||||
|
onOpenMedia: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocCell({
|
||||||
|
status,
|
||||||
|
woType,
|
||||||
|
mediaCount = 0,
|
||||||
|
onOpen,
|
||||||
|
onOpenMedia,
|
||||||
|
}: CompDocCellProps) {
|
||||||
|
if (isMediaWO(woType)) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenMedia();
|
||||||
|
}}
|
||||||
|
title="Photos & videos"
|
||||||
|
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold text-[var(--color-primary)] transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<ImageIcon size={15} className="shrink-0 text-[var(--color-primary)]" />
|
||||||
|
{mediaCount > 0 ? `${mediaCount} file${mediaCount !== 1 ? "s" : ""}` : "Media"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = status === "Yes";
|
||||||
|
const notRequired = status === "NN";
|
||||||
|
const color = generated ? "var(--color-success)" : "var(--color-text-muted)";
|
||||||
|
const Icon = notRequired ? Ban : FileText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
generated
|
||||||
|
? "View completion document"
|
||||||
|
: notRequired
|
||||||
|
? "Completion document — not required"
|
||||||
|
: "Completion document — draft"
|
||||||
|
}
|
||||||
|
className="flex w-full items-center gap-1.5 whitespace-nowrap text-xs font-semibold transition-opacity hover:opacity-80"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
<Icon size={15} className="shrink-0" style={{ color }} />
|
||||||
|
{generated ? "View Doc" : notRequired ? "N/N" : "Draft"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CalendarDays, Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
import { MiniCalendar } from "@/app/(protected)/workorders/_components/wizard/mini-calendar";
|
||||||
|
import { WeekPicker } from "@/app/(protected)/workorders/_components/wizard/week-picker";
|
||||||
|
import { fmtWeekOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { HighlightText } from "./highlight-text";
|
||||||
|
|
||||||
|
type DateCellProps = {
|
||||||
|
value: string;
|
||||||
|
q?: string;
|
||||||
|
highlight?: boolean;
|
||||||
|
onSave: (date: string) => void;
|
||||||
|
allowWeekMode?: boolean;
|
||||||
|
targetWeek?: string;
|
||||||
|
onSaveWeek?: (weekStart: string) => void;
|
||||||
|
defaultMode?: "week" | "date";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DateCell({
|
||||||
|
value,
|
||||||
|
q,
|
||||||
|
highlight,
|
||||||
|
onSave,
|
||||||
|
allowWeekMode,
|
||||||
|
targetWeek = "",
|
||||||
|
onSaveWeek,
|
||||||
|
defaultMode,
|
||||||
|
}: DateCellProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||||
|
allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date",
|
||||||
|
);
|
||||||
|
const showWeek = allowWeekMode && !value && !!targetWeek;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
className="group/cell flex w-full items-center whitespace-nowrap rounded transition-opacity hover:opacity-80"
|
||||||
|
style={
|
||||||
|
highlight
|
||||||
|
? {
|
||||||
|
boxShadow: "0 0 0 2px var(--color-accent)",
|
||||||
|
background: "var(--color-primary-tint)",
|
||||||
|
padding: "2px 8px",
|
||||||
|
margin: "0 -8px",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||||
|
{showWeek ? (
|
||||||
|
<>
|
||||||
|
<CalendarDays size={10} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
<span className="truncate text-[11px] font-medium text-[var(--color-text-muted)]">
|
||||||
|
{fmtWeekOf(targetWeek)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CalendarIcon
|
||||||
|
size={11}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: highlight ? "var(--primary)" : "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
{value ? (
|
||||||
|
<span className="truncate text-[13px] text-[var(--color-text-secondary)]">
|
||||||
|
<HighlightText text={value.replace(/^\d{4}-/, "")} q={q} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="ml-2 shrink-0 opacity-0 transition-opacity group-hover/cell:opacity-100"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }}
|
||||||
|
>
|
||||||
|
{allowWeekMode && (
|
||||||
|
<div className="mb-2.5 flex items-center gap-1.5">
|
||||||
|
{(["week", "date"] as const).map((m) => {
|
||||||
|
const active = cellMode === m;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCellMode(m)}
|
||||||
|
className="rounded-full border px-3 text-[11.5px] font-medium"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
background: active ? "var(--color-primary-tint)" : "var(--color-bg-surface)",
|
||||||
|
borderColor: active ? "var(--color-primary)" : "var(--color-border)",
|
||||||
|
color: active ? "var(--color-primary)" : "var(--color-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m === "week" ? "Week" : "Specific date"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allowWeekMode && cellMode === "week" ? (
|
||||||
|
<WeekPicker
|
||||||
|
selected={!value ? targetWeek : ""}
|
||||||
|
onPick={(mon) => {
|
||||||
|
onSave("");
|
||||||
|
onSaveWeek?.(mon);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MiniCalendar
|
||||||
|
value={value}
|
||||||
|
onChange={(d) => {
|
||||||
|
onSave(d);
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
{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,270 @@
|
||||||
|
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
|
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { getMockWoWizardLocationDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
export type SitePatch = {
|
||||||
|
site: string;
|
||||||
|
location: string;
|
||||||
|
locationId: string | number;
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
value: string;
|
||||||
|
locationId: string | number;
|
||||||
|
pocName?: string;
|
||||||
|
pocPhone?: string;
|
||||||
|
pocNotes?: string;
|
||||||
|
sites: LocationOption[];
|
||||||
|
onSave: (patch: SitePatch) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractSiteCode(name: string): string {
|
||||||
|
const dash = name.indexOf(" — ");
|
||||||
|
if (dash > 0) {
|
||||||
|
return name.slice(0, dash).trim();
|
||||||
|
}
|
||||||
|
return name.slice(0, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLocationLabel(name: string): string {
|
||||||
|
const dash = name.indexOf(" — ");
|
||||||
|
if (dash > 0) {
|
||||||
|
return name.slice(dash + 3).trim();
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMapsUrl(location: Location): string {
|
||||||
|
const parts = [location.address, location.city, location.state, location.zipCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(parts)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
value,
|
||||||
|
locationId,
|
||||||
|
pocName = "",
|
||||||
|
pocPhone = "",
|
||||||
|
pocNotes = "",
|
||||||
|
sites,
|
||||||
|
onSave,
|
||||||
|
}: SiteDialogProps) {
|
||||||
|
const [code, setCode] = useState(value);
|
||||||
|
const [locId, setLocId] = useState(String(locationId || ""));
|
||||||
|
const [pn, setPn] = useState(pocName);
|
||||||
|
const [pp, setPp] = useState(pocPhone);
|
||||||
|
const [notes, setNotes] = useState(pocNotes);
|
||||||
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
|
|
||||||
|
const isMockLocation = locId.startsWith("mock-");
|
||||||
|
const { data: apiLocationDetail } = useLocationDetail(
|
||||||
|
isMockLocation || !locId ? undefined : locId,
|
||||||
|
);
|
||||||
|
const locationDetail = isMockLocation ? getMockWoWizardLocationDetail(locId) : apiLocationDetail;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCode(value);
|
||||||
|
setLocId(String(locationId || ""));
|
||||||
|
setPn(pocName);
|
||||||
|
setPp(pocPhone);
|
||||||
|
setNotes(pocNotes);
|
||||||
|
setPocFilledFor(String(locationId || ""));
|
||||||
|
}
|
||||||
|
}, [open, value, locationId, pocName, pocPhone, pocNotes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPn(locationDetail.contact ?? "");
|
||||||
|
setPp(locationDetail.phone ?? "");
|
||||||
|
setPocFilledFor(locId);
|
||||||
|
}, [open, locId, locationDetail, pocFilledFor]);
|
||||||
|
|
||||||
|
const selected = sites.find((s) => String(s.id) === locId);
|
||||||
|
|
||||||
|
const handlePick = (id: string) => {
|
||||||
|
const next = sites.find((s) => String(s.id) === id);
|
||||||
|
setLocId(id);
|
||||||
|
if (next) {
|
||||||
|
setCode(extractSiteCode(next.name));
|
||||||
|
}
|
||||||
|
if (id !== pocFilledFor) {
|
||||||
|
setPocFilledFor("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "w-full",
|
||||||
|
style: { borderRadius: 12, maxWidth: 480 },
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||||
|
Site
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="sr-only">Select a site and edit its point of contact</p>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Site</FieldLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={locId}
|
||||||
|
placeholder="Select site …"
|
||||||
|
icon={
|
||||||
|
<MapPin
|
||||||
|
size={14}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search site code or city…"
|
||||||
|
options={sites.map((site) => ({
|
||||||
|
key: String(site.id),
|
||||||
|
label: site.name,
|
||||||
|
}))}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{locationDetail && locId && (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Detail
|
||||||
|
icon={MapPin}
|
||||||
|
text={
|
||||||
|
[
|
||||||
|
locationDetail.address,
|
||||||
|
locationDetail.city,
|
||||||
|
locationDetail.state,
|
||||||
|
locationDetail.zipCode,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ") || "—"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{locationDetail.phone && <Detail icon={Phone} text={locationDetail.phone} />}
|
||||||
|
{locationDetail.contactEmail && (
|
||||||
|
<Detail icon={Mail} text={locationDetail.contactEmail} />
|
||||||
|
)}
|
||||||
|
{locationDetail.address && (
|
||||||
|
<a
|
||||||
|
href={buildMapsUrl(locationDetail)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
<p className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Auto-filled from records — edit if outdated
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
value={pn}
|
||||||
|
onChange={(e) => setPn(e.target.value)}
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={pp}
|
||||||
|
onChange={(e) => setPp(e.target.value)}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
|
onClick={() => {
|
||||||
|
onSave({
|
||||||
|
site: code || (selected ? extractSiteCode(selected.name) : ""),
|
||||||
|
location: selected ? extractLocationLabel(selected.name) : "",
|
||||||
|
locationId: locId,
|
||||||
|
pocName: pn,
|
||||||
|
pocPhone: pp,
|
||||||
|
pocNotes: notes,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { AlertTriangle, Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ALL_WIZARD_STATUSES,
|
||||||
|
type WizardWOStatus,
|
||||||
|
} from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
import { HoverChevron } from "./hover-chevron";
|
||||||
|
|
||||||
|
import { WoTableStatusLabel } from "./wo-table-status-label";
|
||||||
|
|
||||||
|
type StatusCellProps = {
|
||||||
|
wo: WorkOrderTableRow;
|
||||||
|
|
||||||
|
onChangeStatus: (status: WizardWOStatus) => void;
|
||||||
|
|
||||||
|
onOpenSlideOver: () => void;
|
||||||
|
|
||||||
|
onPastDueAttempt?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusCell({
|
||||||
|
wo,
|
||||||
|
|
||||||
|
onChangeStatus,
|
||||||
|
|
||||||
|
onOpenSlideOver,
|
||||||
|
|
||||||
|
onPastDueAttempt,
|
||||||
|
}: StatusCellProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
if (wo.isPastDue) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onPastDueAttempt?.();
|
||||||
|
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
title="This WO missed its service window. Reschedule to unlock status updates."
|
||||||
|
className="group/cell flex w-full cursor-pointer items-center gap-1.5 whitespace-nowrap leading-none transition-opacity hover:opacity-80"
|
||||||
|
style={{ fontSize: 12.5, fontWeight: 600, color: "var(--status-pastdue-text)" }}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
<HoverChevron />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{
|
||||||
|
paper: { className: "w-[264px] overflow-hidden rounded-lg border border-border p-0" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 p-3"
|
||||||
|
style={{ background: "var(--status-pending-bg)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} className="mt-0.5 shrink-0 text-[var(--warning)]" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] leading-snug text-[var(--status-pending-text)]">
|
||||||
|
This work order is past due. A new date must be scheduled before the status can be
|
||||||
|
updated.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onOpenSlideOver();
|
||||||
|
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className="mt-2 block text-[11px] font-medium text-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
Reschedule →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
}}
|
||||||
|
className="group/cell flex w-full items-center gap-1 transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
{wo.status ? (
|
||||||
|
<WoTableStatusLabel status={wo.status} />
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<HoverChevron />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={() => setAnchorEl(null)}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{ paper: { className: "min-w-[168px] rounded-lg border border-border p-1" } }}
|
||||||
|
>
|
||||||
|
{ALL_WIZARD_STATUSES.map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChangeStatus(status);
|
||||||
|
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<WoTableStatusLabel status={status} />
|
||||||
|
|
||||||
|
{wo.status === status && (
|
||||||
|
<Check size={10} className="ml-auto text-[var(--color-primary)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
{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,272 @@
|
||||||
|
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
|
import { ExternalLink, Mail, Wrench } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
export type VendorPatch = {
|
||||||
|
company: string;
|
||||||
|
tech: string;
|
||||||
|
techPhone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VendorDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
company: string;
|
||||||
|
tech: string;
|
||||||
|
techPhone?: string;
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
onSave: (patch: VendorPatch) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TechnicianOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
vendor?: VendorDropdownItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Known tech ↔ company pairs from seeded table mock data. */
|
||||||
|
const MOCK_TECH_BY_COMPANY: Record<string, { name: string; phone: string }> = {
|
||||||
|
"Midwest HVAC Co.": { name: "Mike Torres", phone: "(312) 555-0198" },
|
||||||
|
"Great Lakes Plumbing": { name: "Jen Walsh", phone: "(317) 555-4412" },
|
||||||
|
"SparkLine Electric": { name: "Pat Nguyen", phone: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugCompany(name: string): string {
|
||||||
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = resolvedCompany ? `dispatch@${slugCompany(resolvedCompany)}.com` : "";
|
||||||
|
|
||||||
|
const handlePick = (name: string) => {
|
||||||
|
setT(name);
|
||||||
|
setPhone(phoneForOption(name, technicianOptions));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => onOpenChange(false)}
|
||||||
|
maxWidth={false}
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "w-full",
|
||||||
|
style: { borderRadius: 12, maxWidth: 480 },
|
||||||
|
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DialogTitle className="p-0" style={{ fontSize: 16, color: "var(--color-text-primary)" }}>
|
||||||
|
Technician
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="sr-only">Search and select the technician</p>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician</FieldLabel>
|
||||||
|
<WizardFieldSelect
|
||||||
|
value={t}
|
||||||
|
placeholder="Search technician …"
|
||||||
|
icon={
|
||||||
|
<Wrench
|
||||||
|
size={14}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search technician…"
|
||||||
|
options={technicianOptions.map((option) => ({
|
||||||
|
key: option.key,
|
||||||
|
label: option.label,
|
||||||
|
}))}
|
||||||
|
onPick={handlePick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{t && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Technician Phone</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={DLG_INPUT_STYLE}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{t && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Company</FieldLabel>
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||||
|
{resolvedCompany || "—"}
|
||||||
|
</div>
|
||||||
|
{companyEmail && <Detail icon={Mail} text={companyEmail} />}
|
||||||
|
{selectedVendor?.address && (
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Notes</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Contact preferences, availability, etc."
|
||||||
|
rows={3}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="min-h-0 px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
|
onClick={() => {
|
||||||
|
onSave({ company: resolvedCompany, tech: t, techPhone: phone });
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue