();
+
+ 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 (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label.tsx b/src/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label.tsx
new file mode 100644
index 00000000..4c45f593
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label.tsx b/src/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label.tsx
new file mode 100644
index 00000000..3568a379
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx
new file mode 100644
index 00000000..4c4132fd
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/mini-calendar.tsx
@@ -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 (
+
+
+
+
+ {MONTH_NAMES[viewM - 1]} {viewY}
+
+
+
+
+ {WEEKDAY_HEADER.map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ {cells.map((day, i) => {
+ if (day === null) {
+ return ;
+ }
+ const iso = `${viewY}-${pad2(viewM)}-${pad2(day)}`;
+ const selected = iso === value;
+ const isToday = iso === today;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/week-picker.tsx b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
new file mode 100644
index 00000000..233939b0
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/week-picker.tsx
@@ -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 (
+
+
+
+
+ {MONTH_NAMES[viewM - 1]} {viewY}
+
+
+
+
+ {weeks.map((mon) => {
+ const isSel = Boolean(selected) && selected === mon;
+ const isThis = mon === thisMonday;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx
new file mode 100644
index 00000000..d936e85d
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-field-select.tsx
@@ -0,0 +1,167 @@
+import { useState, type ReactNode } from "react";
+import { Check, ChevronDown } from "lucide-react";
+import { Popover } from "@mui/material";
+import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
+import { useMenuAnchor } from "@/hooks/use-menu-anchor";
+
+export interface WizardMenuOption {
+ key: string;
+ label: string;
+ left?: ReactNode;
+}
+
+type WizardFieldSelectProps = {
+ value: string;
+ placeholder: string;
+ icon?: ReactNode;
+ options: WizardMenuOption[];
+ onPick: (key: string) => void;
+ search?: boolean;
+ searchPlaceholder?: string;
+ disabled?: boolean;
+};
+
+export function WizardFieldSelect({
+ value,
+ placeholder,
+ icon,
+ options,
+ onPick,
+ search = true,
+ searchPlaceholder = "Search…",
+ disabled = false,
+}: WizardFieldSelectProps) {
+ const { anchorEl, open, setAnchor, close } = useMenuAnchor();
+ const [q, setQ] = useState("");
+ const selected = options.find((o) => o.key === value);
+ const filtered = q
+ ? options.filter(
+ (o) =>
+ o.label.toLowerCase().includes(q.toLowerCase()) ||
+ o.key.toLowerCase().includes(q.toLowerCase()),
+ )
+ : options;
+
+ const handleClose = () => {
+ close();
+ setQ("");
+ };
+
+ return (
+ <>
+ {
+ (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 && {icon}}
+ {icon == null && selected?.left != null && (
+ {selected.left}
+ )}
+ {selected ? (
+
+ {selected.label}
+
+ ) : (
+
+ {placeholder}
+
+ )}
+
+
+
+ e.stopPropagation(),
+ },
+ }}
+ >
+ {search && (
+
+ 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)",
+ }}
+ />
+
+ )}
+
+ {filtered.length === 0 ? (
+
+ No matches
+
+ ) : (
+ filtered.map((o) => (
+
+ ))
+ )}
+
+
+ >
+ );
+}
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-input-styles.ts b/src/app/(protected)/workorders/_components/wizard/wizard-input-styles.ts
new file mode 100644
index 00000000..708de8d3
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-input-styles.ts
@@ -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)",
+};
diff --git a/src/app/(protected)/workorders/_components/wizard/wizard-labels.tsx b/src/app/(protected)/workorders/_components/wizard/wizard-labels.tsx
new file mode 100644
index 00000000..0d75b5be
--- /dev/null
+++ b/src/app/(protected)/workorders/_components/wizard/wizard-labels.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (active) {
+ return (
+
+ {n}
+
+ );
+ }
+
+ return (
+
+ {n}
+
+ );
+}
+
+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 (
+
+ optional
+
+ );
+}
+
+export function WizLabel({
+ children,
+ optional,
+ required,
+ leading,
+}: {
+ children: ReactNode;
+ optional?: boolean;
+ required?: boolean;
+ leading?: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function WizSubLabel({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function WizLabelRow({
+ children,
+ optional,
+ onClear,
+}: {
+ children: ReactNode;
+ optional?: boolean;
+ onClear?: () => void;
+}) {
+ return (
+
+
+ {children}
+ {optional && }
+
+ {onClear && (
+
+ )}
+
+ );
+}
+
+export function Detail({ icon: Icon, text }: { icon: ElementType; text: string }) {
+ return (
+
+
+ {text}
+
+ );
+}
+
+export function ColorDot({ color }: { color: string }) {
+ return (
+
+ );
+}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index 98de7ec7..3fca88e1 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -1,11 +1,7 @@
import { useMemo, useState } from "react";
import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
-import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
-import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
-import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
-import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
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";
@@ -20,7 +16,6 @@ import {
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
-import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
@@ -28,12 +23,7 @@ import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const { user } = useAuthContext();
- const [wizardOpen, setWizardOpen] = useState(false);
- const [slideOverRow, setSlideOverRow] = useState(null);
- const [slideOverEdit, setSlideOverEdit] = useState(false);
- const [docRow, setDocRow] = useState(null);
const [mediaRow, setMediaRow] = useState(null);
- const [completionDocs, setCompletionDocs] = useState>({});
const [mediaFiles, setMediaFiles] = useState>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
@@ -52,29 +42,14 @@ export default function WorkOrdersListPage() {
[problemOptions],
);
- const vendorTechs = useMemo(
- () => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
- [vendors],
- );
-
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
- const activeDocRow = useMemo(() => {
- if (!docRow) return null;
- return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
- }, [docRow, tableData.rows]);
-
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
- const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
- setSlideOverRow(row);
- setSlideOverEdit(edit);
- };
-
return (
filters.setAdvOpen(true)}
- onNewWorkOrder={() => setWizardOpen(true)}
+ onNewWorkOrder={() => undefined}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
@@ -123,38 +98,13 @@ export default function WorkOrdersListPage() {
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
- onOpenSlideOver={handleOpenSlideOver}
- onOpenCompDoc={setDocRow}
+ onOpenSlideOver={(row) => tableData.setSelectedId(row.id)}
+ onOpenCompDoc={() => undefined}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
/>
-
-
-
-
- {
- setSlideOverRow(null);
- setSlideOverEdit(false);
- tableData.setSelectedId(null);
- }}
- />
-
{activeMediaRow && (
)}
-
- {activeDocRow && (
- {
- if (!open) setDocRow(null);
- }}
- data={completionDocs[String(activeDocRow.id)]}
- onSave={(data, status) => {
- setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
- patchField(activeDocRow.id, { docStatus: status });
- }}
- onStatusChange={(status) => patchField(activeDocRow.id, { status })}
- onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
- />
- )}
);
}
diff --git a/src/domain/work-orders/mappers/work-order-mapper.ts b/src/domain/work-orders/mappers/work-order-mapper.ts
index 687aa3b1..4948d76a 100644
--- a/src/domain/work-orders/mappers/work-order-mapper.ts
+++ b/src/domain/work-orders/mappers/work-order-mapper.ts
@@ -411,6 +411,7 @@ export function mapEmployeeOptions(rawItems: unknown[]): EmployeeOption[] {
export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
const item = asRecord(raw);
+ const email = readString(item, "email", "Email");
return {
id: readId(item),
@@ -418,6 +419,7 @@ export function mapVendorDropdownItem(raw: unknown): VendorDropdownItem {
tradeSpecialties: readString(item, "tradeSpecialties", "TradeSpecialties"),
address: readString(item, "address", "Address"),
distanceMiles: readNumber(item, "distanceMiles", "DistanceMiles"),
+ email: email || undefined,
};
}
diff --git a/src/domain/work-orders/types/work-order-common.ts b/src/domain/work-orders/types/work-order-common.ts
index 0b0f47c0..b380a550 100644
--- a/src/domain/work-orders/types/work-order-common.ts
+++ b/src/domain/work-orders/types/work-order-common.ts
@@ -39,6 +39,8 @@ export interface VendorDropdownItem {
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
+ /** Present when the dropdown/API provides a contact email; never synthesized client-side. */
+ email?: string;
}
export interface TaskListTemplate {