diff --git a/src/app/(protected)/workorders/[id].tsx b/src/app/(protected)/workorders/[id].tsx
index 55a32726..a282040c 100644
--- a/src/app/(protected)/workorders/[id].tsx
+++ b/src/app/(protected)/workorders/[id].tsx
@@ -192,7 +192,10 @@ export default function WorkOrderViewPage() {
}
commentMutation.mutate(
- { text: commentText.trim(), commentType: activeTab },
+ {
+ text: commentText.trim(),
+ commentType: activeTab === "customer" ? "customer" : "internal",
+ },
{ onSuccess: () => setCommentText("") },
);
};
diff --git a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
index 1595d50a..5dd6952a 100644
--- a/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
+++ b/src/app/(protected)/workorders/_components/dispatch-create-modal.tsx
@@ -96,7 +96,7 @@ export function DispatchCreateModal({
.filter(
(item) =>
!woSearch ||
- item.number.includes(woSearch) ||
+ item.woNumber.includes(woSearch) ||
item.title.toLowerCase().includes(woSearch.toLowerCase()),
)
.slice(0, 10),
@@ -180,13 +180,13 @@ export function DispatchCreateModal({
onClick={() => {
setSelectedWOs((prev) => [
...prev,
- { id: item.id, number: item.number, title: item.title },
+ { id: item.id, number: item.woNumber, title: item.title },
]);
setWoSearch("");
}}
>
- {item.number} — {item.title}
+ {item.woNumber} — {item.title}
))}
diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx
index c33f1a2e..5fabb151 100644
--- a/src/app/(protected)/workorders/index.tsx
+++ b/src/app/(protected)/workorders/index.tsx
@@ -28,7 +28,7 @@ import {
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
-import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import {
useWorkOrderUsers,
useWorkOrdersList,
@@ -271,7 +271,7 @@ export default function WorkOrdersListPage() {
) : (
- rows.map((row: WorkOrderListItem) => (
+ rows.map((row: WorkOrderTableRow) => (
{row.title}
@@ -290,12 +290,10 @@ export default function WorkOrdersListPage() {
- {formatDateTime(row.createdDate)}
-
- {row.assignedTo || "Unassigned"}
-
- {formatDateTime(row.lastUpdated)}
+ {formatDateTime(row.scheduledOn)}
+ {row.dispatcherName || "Unassigned"}
+ {formatDateTime(row.dueDate)}
[dispatcher.id, dispatcher.color]),
+) as Record;
+
+const FALLBACK_AVATAR_COLORS = [
+ "#F59E0B",
+ "#0D9488",
+ "#6366F1",
+ "#F43F5E",
+ "#7C3AED",
+ "#0EA5E9",
+ "#16A34A",
+ "#EA580C",
+];
+
+function paletteColorFromKey(key: string): string {
+ let hash = 0;
+ for (let i = 0; i < key.length; i += 1) {
+ hash = key.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ return FALLBACK_AVATAR_COLORS[Math.abs(hash) % FALLBACK_AVATAR_COLORS.length];
+}
+
+export function getDispatcherAvatarColor(user: WorkOrderUser): string {
+ return (
+ MOCK_DISPATCHER_COLOR_BY_ID[String(user.id)] ??
+ paletteColorFromKey(String(user.id || user.name))
+ );
+}
diff --git a/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
new file mode 100644
index 00000000..5daac141
--- /dev/null
+++ b/src/domain/work-orders/mocks/is-wo-table-mock-enabled.ts
@@ -0,0 +1,10 @@
+import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
+
+/** Use seeded WO table rows instead of GetWorkOrderList (dev / auth bypass). */
+export function isWoTableMockEnabled(): boolean {
+ return isAuthBypassEnabled() || import.meta.env.VITE_WO_TABLE_MOCK === "true";
+}
+
+export function shouldUseMockWorkOrderDetail(id: string | number): boolean {
+ return isWoTableMockEnabled() && String(id).startsWith("mock-wo-");
+}
diff --git a/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
new file mode 100644
index 00000000..9420dd2e
--- /dev/null
+++ b/src/domain/work-orders/mocks/is-wo-wizard-mock-enabled.ts
@@ -0,0 +1,10 @@
+import { env } from "@/lib/env";
+import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
+
+export function isWoWizardMockEnabled(): boolean {
+ return env.isDev;
+}
+
+export function shouldUseMockWoWizardCreate(locationId: string): boolean {
+ return isAuthBypassEnabled() || locationId.startsWith("mock-");
+}
diff --git a/src/domain/work-orders/mocks/wo-table-mock-data.ts b/src/domain/work-orders/mocks/wo-table-mock-data.ts
new file mode 100644
index 00000000..d1860745
--- /dev/null
+++ b/src/domain/work-orders/mocks/wo-table-mock-data.ts
@@ -0,0 +1,521 @@
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderDetail,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
+import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
+import { MOCK_DISPATCHER_USERS } from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
+import {
+ MOCK_WO_WIZARD_LOCATIONS,
+ MOCK_WO_WIZARD_SERVICES,
+ MOCK_WO_WIZARD_VENDORS,
+} from "@/domain/work-orders/mocks/wo-wizard-mock-data";
+import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
+import { deriveDayFields, isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
+import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
+import { addDaysIso, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export const MOCK_WO_TABLE_USERS: WorkOrderUser[] = MOCK_DISPATCHER_USERS.map(({ id, name }) => ({
+ id,
+ name,
+}));
+
+function sched(iso: string): Pick {
+ const { dayGroup, dayLabel } = deriveDayFields(iso);
+ return { scheduledOn: iso, dayGroup, dayLabel };
+}
+
+type RowSeed = {
+ id: string | number;
+ woNumber: string;
+ site: string;
+ location: string;
+ locationId: string | number;
+ type: WorkOrderTableRow["type"];
+ dispatcherId: string;
+ dispatcherName: string;
+ scheduledOn?: string;
+ targetWeek?: string;
+ dueDate: string;
+ pm: string;
+ company: string;
+ tech: string;
+ techPhone: string;
+ apptStart: string;
+ apptEnd: string;
+ apptTime: string;
+ status: WorkOrderTableRow["status"];
+ docStatus: WorkOrderTableRow["docStatus"];
+ pocName: string;
+ pocPhone: string;
+ pocNotes: string;
+ rescheduleCount: number;
+ carriedOver: number;
+ originalWeek: string;
+ originalDate: string;
+ scheduleWeekOnly: boolean;
+ mediaCount: number;
+ title?: string;
+ priority?: string;
+};
+
+function buildRow(seed: RowSeed): WorkOrderTableRow {
+ const scheduledOn = seed.scheduledOn ?? "";
+ const dayFields = scheduledOn ? sched(scheduledOn) : deriveDayFields("");
+ return enrichTableRow({
+ title: seed.title ?? `${seed.type || "Work Order"} — ${seed.site}`,
+ priority: seed.priority ?? "Sev 3",
+ ...seed,
+ ...dayFields,
+ scheduledOn,
+ targetWeek: seed.targetWeek ?? "",
+ isPastDue: false,
+ });
+}
+
+function buildMockRows(): WorkOrderTableRow[] {
+ const mon = currentWeekMonday();
+ const tue = addDaysIso(mon, 1);
+ const wed = addDaysIso(mon, 2);
+ const thu = addDaysIso(mon, 3);
+ const fri = addDaysIso(mon, 4);
+ const yesterday = addDaysIso(todayIso(), -1);
+
+ const sarah = MOCK_DISPATCHER_USERS[0];
+ const alyssa = MOCK_DISPATCHER_USERS[1];
+ const derek = MOCK_DISPATCHER_USERS[2];
+ const morgan = MOCK_DISPATCHER_USERS[3];
+
+ return [
+ buildRow({
+ id: "mock-wo-1",
+ woNumber: "20260623001",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "PM",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(mon),
+ dueDate: fri,
+ pm: "HVAC — Quarterly PM",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "09:00",
+ apptEnd: "11:00",
+ apptTime: "09:00 – 11:00",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "Loading dock B",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-2",
+ woNumber: "20260623002",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "Emergency",
+ dispatcherId: String(alyssa.id),
+ dispatcherName: alyssa.name,
+ ...sched(tue),
+ dueDate: tue,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "08:00",
+ apptEnd: "",
+ apptTime: "08:00",
+ status: "On Site",
+ docStatus: "No",
+ pocName: "Chris Bennett",
+ pocPhone: "(317) 555-2208",
+ pocNotes: "",
+ rescheduleCount: 1,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 3,
+ }),
+ buildRow({
+ id: "mock-wo-3",
+ woNumber: "20260623003",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "Reactive",
+ dispatcherId: String(derek.id),
+ dispatcherName: derek.name,
+ ...sched(wed),
+ dueDate: thu,
+ pm: "Electrical — Panel inspection",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "13:00",
+ apptEnd: "15:00",
+ apptTime: "13:00 – 15:00",
+ status: "In Progress",
+ docStatus: "Yes",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "Badge required at front desk",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-4",
+ woNumber: "20260623004",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Add-On",
+ dispatcherId: String(morgan.id),
+ dispatcherName: morgan.name,
+ ...sched(thu),
+ dueDate: fri,
+ pm: "Roof — Membrane patch",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "10:30",
+ apptEnd: "12:00",
+ apptTime: "10:30 – 12:00",
+ status: "Pending",
+ docStatus: "NN",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-5",
+ woNumber: "20260623005",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "PM",
+ dispatcherId: "",
+ dispatcherName: "",
+ ...sched(fri),
+ dueDate: fri,
+ pm: "HVAC — Quarterly PM",
+ company: "",
+ tech: "",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "",
+ pocPhone: "",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-6",
+ woNumber: "20260623006",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "Overdue",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(mon),
+ dueDate: yesterday,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "07:00",
+ apptEnd: "09:00",
+ apptTime: "07:00 – 09:00",
+ status: "Scheduled",
+ docStatus: "No",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "",
+ rescheduleCount: 2,
+ carriedOver: 1,
+ originalWeek: "",
+ originalDate: yesterday,
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-7",
+ woNumber: "20260623007",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Reactive",
+ dispatcherId: String(alyssa.id),
+ dispatcherName: alyssa.name,
+ scheduledOn: "",
+ targetWeek: mon,
+ dueDate: wed,
+ pm: "Electrical — Panel inspection",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Incomplete",
+ docStatus: "No",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "Week-only target",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: mon,
+ originalDate: "",
+ scheduleWeekOnly: true,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-8",
+ woNumber: "20260623008",
+ site: "IND-2208",
+ location: "Indianapolis North",
+ locationId: "mock-loc-2",
+ type: "PM",
+ dispatcherId: String(derek.id),
+ dispatcherName: derek.name,
+ scheduledOn: "",
+ targetWeek: "",
+ dueDate: thu,
+ pm: "HVAC — Quarterly PM",
+ company: "Midwest HVAC Co.",
+ tech: "Mike Torres",
+ techPhone: "(312) 555-0198",
+ apptStart: "",
+ apptEnd: "",
+ apptTime: "",
+ status: "Pending",
+ docStatus: "No",
+ pocName: "Chris Bennett",
+ pocPhone: "(317) 555-2208",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-9",
+ woNumber: "20260623009",
+ site: "MIL-3315",
+ location: "Milwaukee West",
+ locationId: "mock-loc-3",
+ type: "PM",
+ dispatcherId: String(morgan.id),
+ dispatcherName: morgan.name,
+ ...sched(wed),
+ dueDate: fri,
+ pm: "Roof — Membrane patch",
+ company: "SparkLine Electric",
+ tech: "Pat Nguyen",
+ techPhone: "",
+ apptStart: "14:00",
+ apptEnd: "16:00",
+ apptTime: "14:00 – 16:00",
+ status: "En Route",
+ docStatus: "No",
+ pocName: "Taylor Brooks",
+ pocPhone: "(414) 555-3315",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 0,
+ }),
+ buildRow({
+ id: "mock-wo-10",
+ woNumber: "20260623010",
+ site: "CHI-1042",
+ location: "Chicago Loop",
+ locationId: "mock-loc-1",
+ type: "Reactive",
+ dispatcherId: String(sarah.id),
+ dispatcherName: sarah.name,
+ ...sched(tue),
+ dueDate: wed,
+ pm: "Plumbing — Leak repair",
+ company: "Great Lakes Plumbing",
+ tech: "Jen Walsh",
+ techPhone: "(317) 555-4412",
+ apptStart: "11:00",
+ apptEnd: "12:30",
+ apptTime: "11:00 – 12:30",
+ status: "Completed",
+ docStatus: "Yes",
+ pocName: "Maria Gonzalez",
+ pocPhone: "(312) 555-0142",
+ pocNotes: "",
+ rescheduleCount: 0,
+ carriedOver: 0,
+ originalWeek: "",
+ originalDate: "",
+ scheduleWeekOnly: false,
+ mediaCount: 2,
+ }),
+ ];
+}
+
+let cachedRows: WorkOrderTableRow[] | null = null;
+
+export function getMockWorkOrderTableRows(): WorkOrderTableRow[] {
+ if (!cachedRows) {
+ cachedRows = buildMockRows();
+ }
+ return cachedRows;
+}
+
+function rowMatchesSearch(row: WorkOrderTableRow, search: string): boolean {
+ const q = search.trim().toLowerCase();
+ if (!q) {
+ return true;
+ }
+ const haystack = [
+ row.site,
+ row.location,
+ row.woNumber,
+ row.type,
+ row.dispatcherName,
+ row.pm,
+ row.company,
+ row.tech,
+ row.status,
+ row.title,
+ ]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(q);
+}
+
+export function getMockWorkOrdersList(params: WorkOrdersListParams = {}): {
+ items: WorkOrderTableRow[];
+ totalCount: number;
+ page: number;
+ pageSize: number;
+} {
+ let items = getMockWorkOrderTableRows();
+
+ if (params.search) {
+ items = items.filter((row) => rowMatchesSearch(row, params.search!));
+ }
+
+ if (params.type) {
+ items = items.filter((row) => row.type === params.type);
+ }
+
+ if (params.dateFrom && params.dateTo) {
+ const weekMonday = params.dateFrom;
+ items = items.filter((row) => {
+ if (row.scheduledOn) {
+ return row.scheduledOn >= params.dateFrom! && row.scheduledOn <= params.dateTo!;
+ }
+ if (row.targetWeek) {
+ return isDateInWeek(row.targetWeek, weekMonday);
+ }
+ return true;
+ });
+ }
+
+ const page = params.page ?? 1;
+ const pageSize = params.pageSize ?? items.length;
+
+ return {
+ items,
+ totalCount: items.length,
+ page,
+ pageSize,
+ };
+}
+
+export function getMockWorkOrderTableRow(id: string | number): WorkOrderTableRow | undefined {
+ return getMockWorkOrderTableRows().find((row) => String(row.id) === String(id));
+}
+
+export function buildMockWorkOrderDetailFromTableRow(row: WorkOrderTableRow): WorkOrderDetail {
+ return {
+ id: row.id,
+ internalWONumber: row.woNumber,
+ workOrderNumber: row.woNumber,
+ title: row.title,
+ location: row.location,
+ locationId: String(row.locationId),
+ locationZip: "",
+ assignedTo: row.dispatcherName,
+ dueDate: row.dueDate,
+ createdDate: addDaysIso(todayIso(), -7),
+ createdDateFull: new Date().toISOString(),
+ description: [
+ row.pocNotes && `POC notes: ${row.pocNotes}`,
+ row.pocName && `POC: ${row.pocName} · ${row.pocPhone}`,
+ row.apptTime && row.apptTime !== "—" && `Appt: ${row.apptTime}`,
+ row.company && `Vendor: ${row.company}`,
+ ]
+ .filter(Boolean)
+ .join("\n"),
+ priority: row.priority,
+ status:
+ row.status === "Completed" ? "Done" : row.status === "Incomplete" ? "Open" : "In Progress",
+ po: "",
+ tt: "",
+ problem: row.pm,
+ trade: "",
+ subTrade: "",
+ vendorNTE: "",
+ scheduledDate: row.scheduledOn,
+ completedDate: row.status === "Completed" ? todayIso() : "",
+ source: row.type,
+ comments: [],
+ auditLog: [],
+ dispatches: [],
+ };
+}
+
+export function getMockWorkOrderLocations(): LocationOption[] {
+ return MOCK_WO_WIZARD_LOCATIONS;
+}
+
+export function getMockWorkOrderVendors(): VendorDropdownItem[] {
+ return MOCK_WO_WIZARD_VENDORS;
+}
+
+export function getMockPmTypes(): string[] {
+ return MOCK_WO_WIZARD_SERVICES.map((s) => s.value).filter(Boolean);
+}
diff --git a/src/domain/work-orders/mocks/wo-wizard-mock-data.ts b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
new file mode 100644
index 00000000..075a250d
--- /dev/null
+++ b/src/domain/work-orders/mocks/wo-wizard-mock-data.ts
@@ -0,0 +1,158 @@
+import type { Location } from "@/domain/locations/types/location";
+import type { DropdownOption } from "@/domain/settings/dropdown-options/types/dropdown-option";
+import type {
+ LocationOption,
+ VendorDropdownItem,
+ WorkOrderDetail,
+ WorkOrderFormValues,
+ WorkOrderUser,
+} from "@/domain/work-orders/types/work-order";
+
+export const MOCK_WO_WIZARD_USERS: WorkOrderUser[] = [
+ { id: "mock-user-1", name: "Alex Rivera" },
+ { id: "mock-user-2", name: "Jordan Kim" },
+ { id: "mock-user-3", name: "Sam Patel" },
+];
+
+export const MOCK_WO_WIZARD_LOCATIONS: LocationOption[] = [
+ { id: "mock-loc-1", name: "CHI-1042 — Chicago Loop" },
+ { id: "mock-loc-2", name: "IND-2208 — Indianapolis North" },
+ { id: "mock-loc-3", name: "MIL-3315 — Milwaukee West" },
+];
+
+const MOCK_LOCATION_DETAILS: Record = {
+ "mock-loc-1": {
+ id: "mock-loc-1",
+ name: "CHI-1042 — Chicago Loop",
+ address: "233 S Wacker Dr",
+ city: "Chicago",
+ state: "IL",
+ zipCode: "60606",
+ phone: "(312) 555-0142",
+ contact: "Maria Gonzalez",
+ contactEmail: "maria.gonzalez@example.com",
+ status: "Active",
+ },
+ "mock-loc-2": {
+ id: "mock-loc-2",
+ name: "IND-2208 — Indianapolis North",
+ address: "8702 Keystone Crossing",
+ city: "Indianapolis",
+ state: "IN",
+ zipCode: "46240",
+ phone: "(317) 555-2208",
+ contact: "Chris Bennett",
+ contactEmail: "chris.bennett@example.com",
+ status: "Active",
+ },
+ "mock-loc-3": {
+ id: "mock-loc-3",
+ name: "MIL-3315 — Milwaukee West",
+ address: "411 E Wisconsin Ave",
+ city: "Milwaukee",
+ state: "WI",
+ zipCode: "53202",
+ phone: "(414) 555-3315",
+ contact: "Taylor Brooks",
+ contactEmail: "taylor.brooks@example.com",
+ status: "Active",
+ },
+};
+
+export const MOCK_WO_WIZARD_SERVICES: DropdownOption[] = [
+ {
+ id: "mock-svc-1",
+ category: "Problem",
+ value: "HVAC — Quarterly PM",
+ parentValue: null,
+ sortOrder: 1,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-2",
+ category: "Problem",
+ value: "Plumbing — Leak repair",
+ parentValue: null,
+ sortOrder: 2,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-3",
+ category: "Problem",
+ value: "Electrical — Panel inspection",
+ parentValue: null,
+ sortOrder: 3,
+ isActive: true,
+ },
+ {
+ id: "mock-svc-4",
+ category: "Problem",
+ value: "Roof — Membrane patch",
+ parentValue: null,
+ sortOrder: 4,
+ isActive: true,
+ },
+];
+
+export const MOCK_WO_WIZARD_VENDORS: VendorDropdownItem[] = [
+ {
+ id: "mock-vendor-1",
+ companyName: "Midwest HVAC Co.",
+ tradeSpecialties: "HVAC",
+ address: "1200 Industrial Pkwy, Chicago, IL 60632",
+ distanceMiles: 4.2,
+ },
+ {
+ id: "mock-vendor-2",
+ companyName: "Great Lakes Plumbing",
+ tradeSpecialties: "Plumbing",
+ address: "455 Water St, Milwaukee, WI 53202",
+ distanceMiles: 8.7,
+ },
+ {
+ id: "mock-vendor-3",
+ companyName: "SparkLine Electric",
+ tradeSpecialties: "Electrical",
+ address: "88 Volt Ave, Indianapolis, IN 46225",
+ distanceMiles: 12.1,
+ },
+];
+
+export function getMockWoWizardLocationDetail(locationId: string): Location | undefined {
+ return MOCK_LOCATION_DETAILS[locationId];
+}
+
+export function buildMockWorkOrderDetail(values: WorkOrderFormValues): WorkOrderDetail {
+ const id = `mock-wo-${Date.now()}`;
+ const now = new Date().toISOString();
+
+ return {
+ id,
+ internalWONumber: values.internalWONumber || id,
+ workOrderNumber: values.workOrderNumber || "MOCK-0001",
+ title: values.title || "Mock Work Order",
+ location:
+ MOCK_WO_WIZARD_LOCATIONS.find((l) => String(l.id) === values.locationId)?.name ?? "Mock Site",
+ locationId: values.locationId,
+ locationZip: getMockWoWizardLocationDetail(values.locationId)?.zipCode ?? "",
+ assignedTo: values.assignedTo,
+ dueDate: values.dueDate,
+ createdDate: values.createdDate,
+ createdDateFull: now,
+ description: values.description,
+ priority: values.priority,
+ status: values.status,
+ po: values.po,
+ tt: values.tt,
+ problem: values.problem,
+ trade: values.trade,
+ subTrade: values.subTrade,
+ vendorNTE: values.vendorNTE,
+ scheduledDate: values.scheduledDate,
+ completedDate: values.completedDate,
+ source: values.source,
+ comments: [],
+ auditLog: [],
+ dispatches: [],
+ };
+}
diff --git a/src/domain/work-orders/utils/advanced-filters-utils.ts b/src/domain/work-orders/utils/advanced-filters-utils.ts
new file mode 100644
index 00000000..ad132df4
--- /dev/null
+++ b/src/domain/work-orders/utils/advanced-filters-utils.ts
@@ -0,0 +1,240 @@
+import {
+ ASSIGNEE_FILTER_UNASSIGNED,
+ DATE_RANGE_OPTIONS,
+ DOC_FILTER_LABELS,
+ EMPTY_ADVANCED_FILTERS,
+ type CompletionDocFilter,
+ type DateRangeKey,
+ type WorkOrderAdvancedFilters,
+} from "@/domain/work-orders/types/work-order-advanced-filters";
+import {
+ MONTH_NAMES,
+ addDaysIso,
+ mondayOf,
+ pad2,
+ todayIso,
+} from "@/domain/work-orders/utils/wizard-date-utils";
+
+export interface AdvancedFilterChip {
+ key: string;
+ group: string;
+ value?: string;
+ count?: number;
+ accent?: boolean;
+ clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters;
+}
+
+function cloneFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
+ return {
+ ...filters,
+ sites: new Set(filters.sites),
+ types: new Set(filters.types),
+ dispatchers: new Set(filters.dispatchers),
+ statuses: new Set(filters.statuses),
+ pmTypes: new Set(filters.pmTypes),
+ vendorTechs: new Set(filters.vendorTechs),
+ docs: new Set(filters.docs),
+ };
+}
+
+export function countAdvancedFilters(filters: WorkOrderAdvancedFilters): number {
+ let count = 0;
+ if (filters.dateRange !== "this-week") count += 1;
+ if (filters.sites.size) count += 1;
+ if (filters.types.size) count += 1;
+ if (filters.dispatchers.size) count += 1;
+ if (filters.statuses.size) count += 1;
+ if (filters.pmTypes.size) count += 1;
+ if (filters.vendorTechs.size) count += 1;
+ if (filters.docs.size) count += 1;
+ return count;
+}
+
+function fmtChipDate(iso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
+ const [, m, d] = iso.split("-").map(Number);
+ return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}`;
+}
+
+function monthStart(y: number, m: number): string {
+ return `${y}-${pad2(m + 1)}-01`;
+}
+
+function monthEnd(y: number, m: number): string {
+ return `${y}-${pad2(m + 1)}-${pad2(new Date(y, m + 1, 0).getDate())}`;
+}
+
+export function dateRangeFor(filters: WorkOrderAdvancedFilters): [string, string] {
+ const today = todayIso();
+ const ty = Number(today.slice(0, 4));
+ const tm = Number(today.slice(5, 7)) - 1;
+ const thisWeekMonday = mondayOf(today);
+ const lastWeekMonday = addDaysIso(thisWeekMonday, -7);
+
+ switch (filters.dateRange) {
+ case "this-week":
+ return [thisWeekMonday, addDaysIso(thisWeekMonday, 4)];
+ case "last-week":
+ return [lastWeekMonday, addDaysIso(lastWeekMonday, 4)];
+ case "this-month":
+ return [monthStart(ty, tm), monthEnd(ty, tm)];
+ case "last-3-months": {
+ const d = new Date(ty, tm - 2, 1);
+ return [monthStart(d.getFullYear(), d.getMonth()), monthEnd(ty, tm)];
+ }
+ case "next-week":
+ return [today, addDaysIso(today, 7)];
+ case "next-month":
+ return [today, addDaysIso(today, 30)];
+ case "custom":
+ return [filters.customFrom || "0000-01-01", filters.customTo || "9999-12-31"];
+ }
+}
+
+function setChip(
+ chips: AdvancedFilterChip[],
+ key: string,
+ group: string,
+ set: Set,
+ nameOf: (value: string) => string,
+ clear: (filters: WorkOrderAdvancedFilters) => WorkOrderAdvancedFilters,
+) {
+ if (!set.size) return;
+ if (set.size <= 2) {
+ chips.push({ key, group, value: [...set].map(nameOf).join(", "), clear });
+ return;
+ }
+ chips.push({ key, group, count: set.size, clear });
+}
+
+export function buildAdvancedFilterChips(
+ filters: WorkOrderAdvancedFilters,
+ options?: {
+ siteNameOf?: (id: string) => string;
+ dispatcherNameOf?: (id: string) => string;
+ },
+): AdvancedFilterChip[] {
+ const chips: AdvancedFilterChip[] = [];
+ const siteNameOf = options?.siteNameOf ?? ((v) => v);
+ const dispatcherNameOf = options?.dispatcherNameOf ?? ((v) => v);
+
+ if (filters.dateRange !== "this-week") {
+ const value =
+ filters.dateRange === "custom"
+ ? `${fmtChipDate(filters.customFrom) || "Any"} – ${fmtChipDate(filters.customTo) || "Any"}`
+ : (DATE_RANGE_OPTIONS.find((o) => o.key === filters.dateRange)?.label ?? "Custom");
+ chips.push({
+ key: "date",
+ group: "Date",
+ value,
+ clear: (current) => ({
+ ...current,
+ dateRange: "this-week",
+ customFrom: "",
+ customTo: "",
+ }),
+ });
+ }
+
+ setChip(chips, "sites", "Site", filters.sites, siteNameOf, (current) => ({
+ ...current,
+ sites: new Set(),
+ }));
+
+ setChip(
+ chips,
+ "types",
+ "Type of WO",
+ filters.types as Set,
+ (v) => v,
+ (current) => ({ ...current, types: new Set() }),
+ );
+
+ const realDispatchers = new Set(
+ [...filters.dispatchers].filter((id) => id !== ASSIGNEE_FILTER_UNASSIGNED),
+ );
+ setChip(chips, "disp", "Assigned to", realDispatchers, dispatcherNameOf, (current) => ({
+ ...current,
+ dispatchers: new Set(
+ [...current.dispatchers].filter((id) => id === ASSIGNEE_FILTER_UNASSIGNED),
+ ),
+ }));
+
+ if (filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED)) {
+ chips.push({
+ key: "disp-unassigned",
+ group: "Assigned to",
+ value: "Unassigned",
+ accent: true,
+ clear: (current) => {
+ const next = new Set(current.dispatchers);
+ next.delete(ASSIGNEE_FILTER_UNASSIGNED);
+ return { ...current, dispatchers: next };
+ },
+ });
+ }
+
+ setChip(
+ chips,
+ "status",
+ "Status",
+ filters.statuses as Set,
+ (v) => v,
+ (current) => ({ ...current, statuses: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "pm",
+ "PM type",
+ filters.pmTypes,
+ (v) => v,
+ (current) => ({ ...current, pmTypes: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "vendor",
+ "Vendor / tech",
+ filters.vendorTechs,
+ (v) => v,
+ (current) => ({ ...current, vendorTechs: new Set() }),
+ );
+
+ setChip(
+ chips,
+ "doc",
+ "Comp doc",
+ filters.docs as Set,
+ (v) => DOC_FILTER_LABELS[v as CompletionDocFilter] ?? v,
+ (current) => ({ ...current, docs: new Set() }),
+ );
+
+ return chips;
+}
+
+export function clearAdvancedFilterGroup(
+ filters: WorkOrderAdvancedFilters,
+ chipKey: string,
+): WorkOrderAdvancedFilters | null {
+ const chips = buildAdvancedFilterChips(filters);
+ const chip = chips.find((c) => c.key === chipKey);
+ if (!chip) return filters;
+ const next = chip.clear(cloneFilters(filters));
+ return countAdvancedFilters(next) > 0 ? next : null;
+}
+
+export function getInitialAdvancedFilters(
+ applied: WorkOrderAdvancedFilters | null,
+): WorkOrderAdvancedFilters {
+ if (!applied) return cloneFilters(EMPTY_ADVANCED_FILTERS);
+ return cloneFilters(applied);
+}
+
+export function cloneAdvancedFilters(filters: WorkOrderAdvancedFilters): WorkOrderAdvancedFilters {
+ return cloneFilters(filters);
+}
+
+export function isDefaultDateRange(dateRange: DateRangeKey): boolean {
+ return dateRange === "this-week";
+}
diff --git a/src/domain/work-orders/utils/list-week-utils.ts b/src/domain/work-orders/utils/list-week-utils.ts
new file mode 100644
index 00000000..221ad4bd
--- /dev/null
+++ b/src/domain/work-orders/utils/list-week-utils.ts
@@ -0,0 +1,68 @@
+import { addDaysIso, mondayOf, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export interface WorkWeek {
+ mondayIso: string;
+ label: string;
+ live: boolean;
+}
+
+export function currentWeekMonday(): string {
+ return mondayOf(todayIso());
+}
+
+export function weekRange(mondayIso: string): [string, string] {
+ return [mondayIso, addDaysIso(mondayIso, 4)];
+}
+
+export function compactWeekLabel(mondayIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
+ return "";
+ }
+ const fridayIso = addDaysIso(mondayIso, 4);
+ const [, sm, sd] = mondayIso.split("-").map(Number);
+ const [, em, ed] = fridayIso.split("-").map(Number);
+ return `${sm}/${sd}–${em}/${ed}`;
+}
+
+export function isLiveWeek(mondayIso: string): boolean {
+ return mondayIso === currentWeekMonday();
+}
+
+export function isFutureWeek(mondayIso: string): boolean {
+ return mondayIso > currentWeekMonday();
+}
+
+export function nextWeekMonday(): string {
+ return addDaysIso(currentWeekMonday(), 7);
+}
+
+function toWorkWeek(mondayIso: string): WorkWeek {
+ return {
+ mondayIso,
+ label: compactWeekLabel(mondayIso),
+ live: isLiveWeek(mondayIso),
+ };
+}
+
+export function buildWeekWindow(anchorMonday: string, before = 4, after = 4): WorkWeek[] {
+ const weeks: WorkWeek[] = [];
+ const liveMonday = currentWeekMonday();
+ const upcomingMonday = nextWeekMonday();
+
+ for (let i = -before; i <= after; i += 1) {
+ const mondayIso = addDaysIso(anchorMonday, i * 7);
+ if (isFutureWeek(mondayIso) && mondayIso !== upcomingMonday) {
+ continue;
+ }
+ weeks.push(toWorkWeek(mondayIso));
+ }
+
+ for (const requiredMonday of [liveMonday, upcomingMonday]) {
+ if (!weeks.some((week) => week.mondayIso === requiredMonday)) {
+ weeks.push(toWorkWeek(requiredMonday));
+ }
+ }
+
+ weeks.sort((a, b) => a.mondayIso.localeCompare(b.mondayIso));
+ return weeks;
+}
diff --git a/src/domain/work-orders/utils/table-day-utils.ts b/src/domain/work-orders/utils/table-day-utils.ts
new file mode 100644
index 00000000..49dc123b
--- /dev/null
+++ b/src/domain/work-orders/utils/table-day-utils.ts
@@ -0,0 +1,46 @@
+import { addDaysIso, pad2 } from "@/domain/work-orders/utils/wizard-date-utils";
+
+export const WEEKDAY_LABELS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"] as const;
+
+export function deriveDayFields(rawDate: string): {
+ dayGroup: string;
+ dayLabel: string;
+} {
+ const dayGroup = rawDate ? rawDate.replace(/^\d{4}-(\d{2})-(\d{2})$/, "$1/$2") : "";
+ const dayLabel = rawDate
+ ? (() => {
+ const d = new Date(`${rawDate}T12:00:00`);
+ return `${WEEKDAY_LABELS[d.getDay()]} · ${dayGroup}`;
+ })()
+ : "";
+ return { dayGroup, dayLabel };
+}
+
+export function buildWeekSkeleton(weekMonday: string): {
+ key: string;
+ label: string;
+ date: string;
+ items: never[];
+}[] {
+ const base = new Date(`${weekMonday}T12:00:00`);
+ return Array.from({ length: 5 }, (_, i) => {
+ const d = new Date(base);
+ d.setDate(base.getDate() + i);
+ const key = `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}`;
+ const iso = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ return {
+ key,
+ label: `${WEEKDAY_LABELS[d.getDay()]} · ${key}`,
+ date: iso,
+ items: [],
+ };
+ });
+}
+
+export function isDateInWeek(iso: string, weekMonday: string): boolean {
+ if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
+ return false;
+ }
+ const friday = addDaysIso(weekMonday, 4);
+ return iso >= weekMonday && iso <= friday;
+}
diff --git a/src/domain/work-orders/utils/table-filter-utils.ts b/src/domain/work-orders/utils/table-filter-utils.ts
new file mode 100644
index 00000000..afe15972
--- /dev/null
+++ b/src/domain/work-orders/utils/table-filter-utils.ts
@@ -0,0 +1,166 @@
+import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
+import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
+import type { WorkOrderBarFilters } from "@/domain/work-orders/types/work-order-list-filters";
+import type {
+ WorkOrderDayGroup,
+ WorkOrderTableRow,
+} from "@/domain/work-orders/types/work-order-table-row";
+import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
+import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
+import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
+
+const TERMINAL_STATUSES = new Set(["Completed", "Canceled", "Done", "Cancelled"]);
+
+export function computeIsPastDue(row: WorkOrderTableRow): boolean {
+ if (!row.dueDate || TERMINAL_STATUSES.has(row.status)) {
+ return false;
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ return row.dueDate < today;
+}
+
+export function enrichTableRow(row: WorkOrderTableRow): WorkOrderTableRow {
+ return {
+ ...row,
+ isPastDue: computeIsPastDue(row),
+ };
+}
+
+export function matchesAdvancedFilters(
+ row: WorkOrderTableRow,
+ filters: WorkOrderAdvancedFilters,
+): boolean {
+ const [from, to] = dateRangeFor(filters);
+ if (row.scheduledOn && (row.scheduledOn < from || row.scheduledOn > to)) {
+ return false;
+ }
+ if (filters.sites.size && !filters.sites.has(row.site)) {
+ return false;
+ }
+ if (filters.types.size && !filters.types.has(row.type as never)) {
+ return false;
+ }
+ if (filters.dispatchers.size) {
+ const unassignedMatch =
+ filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
+ const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
+ if (!unassignedMatch && !idMatch) {
+ return false;
+ }
+ }
+ if (filters.statuses.size && (!row.status || !filters.statuses.has(row.status as never))) {
+ return false;
+ }
+ if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) {
+ return false;
+ }
+ if (filters.vendorTechs.size && !filters.vendorTechs.has(row.tech)) {
+ return false;
+ }
+ if (filters.docs.size && !filters.docs.has(row.docStatus as never)) {
+ return false;
+ }
+ return true;
+}
+
+function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
+ const q = query.trim().toLowerCase();
+ if (!q) {
+ return true;
+ }
+ const haystack = [
+ row.site,
+ row.location,
+ row.woNumber,
+ row.type,
+ row.dispatcherName,
+ row.scheduledOn,
+ row.dueDate,
+ row.pm,
+ row.company,
+ row.tech,
+ row.status,
+ row.title,
+ ]
+ .join(" ")
+ .toLowerCase();
+ return haystack.includes(q);
+}
+
+export function filterByBarAndSearch(
+ rows: WorkOrderTableRow[],
+ bar: WorkOrderBarFilters,
+ search: string,
+): WorkOrderTableRow[] {
+ const [weekFrom, weekTo] = weekRange(bar.weekMonday);
+
+ return rows.filter((row) => {
+ if (!rowMatchesSearch(row, search)) {
+ return false;
+ }
+
+ if (bar.type !== "All" && row.type !== bar.type) {
+ return false;
+ }
+
+ if (bar.dispatcherIds.size > 0) {
+ const unassignedMatch =
+ bar.dispatcherIds.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
+ const idMatch = !!row.dispatcherId && bar.dispatcherIds.has(String(row.dispatcherId));
+ if (!unassignedMatch && !idMatch) {
+ return false;
+ }
+ }
+
+ if (row.scheduledOn) {
+ if (row.scheduledOn < weekFrom || row.scheduledOn > weekTo) {
+ return false;
+ }
+ } else if (row.targetWeek) {
+ if (!isDateInWeek(row.targetWeek, bar.weekMonday)) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
+export function splitScheduledUnscheduled(rows: WorkOrderTableRow[]): {
+ scheduled: WorkOrderTableRow[];
+ unscheduled: WorkOrderTableRow[];
+} {
+ const scheduled: WorkOrderTableRow[] = [];
+ const unscheduled: WorkOrderTableRow[] = [];
+
+ for (const row of rows) {
+ if (!row.scheduledOn) {
+ unscheduled.push(row);
+ } else {
+ scheduled.push(row);
+ }
+ }
+
+ return { scheduled, unscheduled };
+}
+
+export function buildDayGroups(scheduled: WorkOrderTableRow[]): WorkOrderDayGroup[] {
+ const groups: WorkOrderDayGroup[] = [];
+
+ for (const row of scheduled) {
+ const existing = groups.find((group) => group.key === row.dayGroup);
+ if (existing) {
+ existing.items.push(row);
+ } else {
+ groups.push({
+ key: row.dayGroup,
+ label: row.dayLabel,
+ date: row.scheduledOn,
+ items: [row],
+ });
+ }
+ }
+
+ groups.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
+ return groups;
+}
diff --git a/src/domain/work-orders/utils/wizard-date-utils.ts b/src/domain/work-orders/utils/wizard-date-utils.ts
new file mode 100644
index 00000000..5f03d7c4
--- /dev/null
+++ b/src/domain/work-orders/utils/wizard-date-utils.ts
@@ -0,0 +1,148 @@
+export const MONTH_NAMES = [
+ "January",
+ "February",
+ "March",
+ "April",
+ "May",
+ "June",
+ "July",
+ "August",
+ "September",
+ "October",
+ "November",
+ "December",
+];
+
+export function pad2(n: number): string {
+ return String(n).padStart(2, "0");
+}
+
+export function todayIso(): string {
+ const d = new Date();
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function fmtDate(iso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
+ return "";
+ }
+ const [y, m, d] = iso.split("-").map(Number);
+ return `${MONTH_NAMES[m - 1].slice(0, 3)} ${d}, ${y}`;
+}
+
+export function addDaysIso(iso: string, days: number): string {
+ const d = new Date(`${iso}T12:00:00`);
+ d.setDate(d.getDate() + days);
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function mondayOf(iso: string): string {
+ const d = new Date(`${iso}T12:00:00`);
+ const dow = d.getDay();
+ d.setDate(d.getDate() + (dow === 0 ? -6 : 1 - dow));
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+}
+
+export function weeksOfMonth(y: number, m: number): string[] {
+ const firstIso = `${y}-${pad2(m)}-01`;
+ const lastIso = `${y}-${pad2(m)}-${pad2(new Date(y, m, 0).getDate())}`;
+ const out: string[] = [];
+ for (let mon = mondayOf(firstIso); mon <= lastIso; mon = addDaysIso(mon, 7)) {
+ if (addDaysIso(mon, 4) >= firstIso) {
+ out.push(mon);
+ }
+ }
+ return out;
+}
+
+export function weekRangeLabel(mondayIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(mondayIso)) {
+ return "";
+ }
+ const fri = addDaysIso(mondayIso, 4);
+ const [, sm, sd] = mondayIso.split("-").map(Number);
+ const [, em, ed] = fri.split("-").map(Number);
+ return `${MONTH_NAMES[sm - 1].slice(0, 3)} ${sd} – ${MONTH_NAMES[em - 1].slice(0, 3)} ${ed}`;
+}
+
+export function fmtWeekRange(startIso: string): string {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(startIso)) {
+ return fmtDate(startIso);
+ }
+ return weekRangeLabel(startIso);
+}
+
+export function fmt12(t: string): string {
+ const [h, m] = t.split(":").map(Number);
+ const ampm = h < 12 ? "AM" : "PM";
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
+ return `${h12}:${String(m).padStart(2, "0")} ${ampm}`;
+}
+
+export function calcDuration(s: string, e: string): string {
+ const [sh, sm] = s.split(":").map(Number);
+ const [eh, em] = e.split(":").map(Number);
+ const mins = eh * 60 + em - (sh * 60 + sm);
+ if (mins <= 0) {
+ return "";
+ }
+ const h = Math.floor(mins / 60);
+ const m = mins % 60;
+ if (h && m) {
+ return `${h}h ${m}m`;
+ }
+ if (h) {
+ return `${h}h`;
+ }
+ return `${m}m`;
+}
+
+export function formatApptRange(start: string, end: string): string {
+ if (!start) {
+ return "—";
+ }
+ return end ? `${fmt12(start)} – ${fmt12(end)}` : fmt12(start);
+}
+
+export const TIME_SLOTS: string[] = (() => {
+ const out: string[] = [];
+ for (let m = 5 * 60; m <= 22 * 60; m += 30) {
+ const h = Math.floor(m / 60);
+ const mm = m % 60;
+ out.push(`${String(h).padStart(2, "0")}:${String(mm).padStart(2, "0")}`);
+ }
+ return out;
+})();
+
+export function parseApptTime(appt: string): { start: string; end: string } {
+ const m = appt.match(/(\d{1,2}):(\d{2})\s*[–-]\s*(\d{1,2}):(\d{2})/);
+ if (m) {
+ return {
+ start: `${m[1].padStart(2, "0")}:${m[2]}`,
+ end: `${m[3].padStart(2, "0")}:${m[4]}`,
+ };
+ }
+ const single = appt.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
+ if (single) {
+ let h = Number(single[1]) % 12;
+ if (/pm/i.test(single[3])) {
+ h += 12;
+ }
+ return { start: `${pad2(h)}:${single[2]}`, end: "" };
+ }
+ return { start: "", end: "" };
+}
+
+export function formatApptTimeValue(start: string, end: string): string {
+ if (!start) {
+ return "";
+ }
+ return end ? `${start} – ${end}` : start;
+}
+
+export function fmtWeekOf(mondayIso: string): string {
+ if (!mondayIso) {
+ return "";
+ }
+ return `Week of ${weekRangeLabel(mondayIso)}`;
+}