diff --git a/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx new file mode 100644 index 00000000..1c9d160f --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/appt-time-cell.tsx @@ -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(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 ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "w-[264px] rounded-lg border border-border p-0" } }} + > +
+
+ + Start + +
+ {TIME_SLOTS.map((t) => ( + + ))} +
+
+
+ + End (optional) + +
+ {TIME_SLOTS.filter((t) => !start || t > start).map((t) => ( + + ))} +
+
+
+
+ + {start && end ? calcDuration(start, end) : start ? fmt12(start) : "—"} + + +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx new file mode 100644 index 00000000..92557ea2 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell.tsx @@ -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(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 ( + <> + + { + 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" } }} + > +
+ 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" + /> +
+
+ {filtered.map((d) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx new file mode 100644 index 00000000..43f258c3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell.tsx @@ -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 ( + + ); + } + + 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 ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx new file mode 100644 index 00000000..ae377351 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/date-cell.tsx @@ -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(null); + const [cellMode, setCellMode] = useState<"week" | "date">( + allowWeekMode ? (defaultMode ?? (!value && targetWeek ? "week" : "date")) : "date", + ); + const showWeek = allowWeekMode && !value && !!targetWeek; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-3" } }} + > + {Boolean(allowWeekMode) && ( +
+ {(["week", "date"] as const).map((m) => { + const active = cellMode === m; + return ( + + ); + })} +
+ )} + {allowWeekMode && cellMode === "week" ? ( + { + onSave(""); + onSaveWeek?.(mon); + setAnchorEl(null); + }} + /> + ) : ( + { + onSave(d); + setAnchorEl(null); + }} + /> + )} +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx new file mode 100644 index 00000000..77ac64e3 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/dispatcher-pill.tsx @@ -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 ( + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx new file mode 100644 index 00000000..ce4b3462 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell.tsx @@ -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 ( + + {count} + + ); +} + +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 ( +
+ {editing ? ( + 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, + }} + /> + ) : ( + { + 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 ? : "-"} + + )} + + + + +
+ ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx new file mode 100644 index 00000000..cda95c32 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/flag-cell.tsx @@ -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(null); + + if (disabled) { + return ( + + + + ); + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + onClick={(e) => e.stopPropagation()} + slotProps={{ paper: { className: "rounded-lg border border-border p-2" } }} + > +
+ {FLAG_COLORS.map((c) => ( + + ))} +
+ +
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx new file mode 100644 index 00000000..95b10c15 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/highlight-text.tsx @@ -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} + {match} + {after} + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx new file mode 100644 index 00000000..69227086 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/hover-chevron.tsx @@ -0,0 +1,11 @@ +import { ChevronDown } from "lucide-react"; + +export function HoverChevron() { + return ( + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx new file mode 100644 index 00000000..4ae184df --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/pm-type-cell.tsx @@ -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(null); + + const [search, setSearch] = useState(""); + + const filtered = search + ? pmTypes.filter((p) => p.toLowerCase().includes(search.toLowerCase())) + : pmTypes; + + return ( + <> + + + { + 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" } }} + > +
+ 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" + /> +
+ +
+ {filtered.map((pm) => ( + + ))} +
+
+ + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx new file mode 100644 index 00000000..6cb860b8 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-cell.tsx @@ -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 ( + <> + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx new file mode 100644 index 00000000..1dc0d678 --- /dev/null +++ b/src/app/(protected)/workorders/_components/list/table/cells/site-dialog.tsx @@ -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 ( + + ); +} + +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 ( + onOpenChange(false)} + maxWidth={false} + slotProps={{ + ...dialogBlurBackdropSlotProps, + paper: { + className: "w-full", + style: { borderRadius: 12, maxWidth: 480 }, + onClick: (e: MouseEvent) => e.stopPropagation(), + }, + }} + > + e.stopPropagation()}> + + Site + + + Select a site and edit its point of contact + + +
+
+ Site + + } + searchPlaceholder="Search site code or city…" + options={sites.map((site) => ({ + key: String(site.id), + label: site.name, + }))} + onPick={handlePick} + /> +
+ + {locationDetail != null && Boolean(locId) && ( +
+ + {locationDetail.phone != null && } + {locationDetail.contactEmail != null && ( + + )} + {Boolean(locationDetail.address) && ( + + Open in Google Maps + + )} +
+ )} + +
+ Point of Contact + + Auto-filled from records — edit if outdated + +
+ setPn(e.target.value)} + placeholder="POC name" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> + setPp(e.target.value)} + placeholder="POC phone" + className={DLG_INPUT_CLS} + style={DLG_INPUT_STYLE} + /> +