feat(work-orders): add board utils and mock data

This commit is contained in:
Arthur Bassi 2026-07-17 12:01:56 -03:00
parent e13bc4c1a2
commit df8f3553e8
16 changed files with 1803 additions and 0 deletions

View file

@ -0,0 +1,45 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
export type MockDispatcherUser = WorkOrderUser & { color: string };
/** UI-only dispatchers until the list filter is wired to the API. */
export const MOCK_DISPATCHER_USERS: MockDispatcherUser[] = [
{ id: "mock-dispatcher-sarah", name: "Sarah Chen", color: "#F59E0B" },
{ id: "mock-dispatcher-alyssa", name: "Alyssa Reeves", color: "#0D9488" },
{ id: "mock-dispatcher-derek", name: "Derek Walsh", color: "#6366F1" },
{ id: "mock-dispatcher-morgan", name: "Morgan Blake", color: "#F43F5E" },
{ id: "mock-dispatcher-james", name: "James Torres", color: "#7C3AED" },
{ id: "mock-dispatcher-rachel", name: "Rachel Kim", color: "#0EA5E9" },
{ id: "mock-dispatcher-tiffany", name: "Tiffany Moore", color: "#16A34A" },
{ id: "mock-dispatcher-brandon", name: "Brandon Lee", color: "#EA580C" },
];
const MOCK_DISPATCHER_COLOR_BY_ID = Object.fromEntries(
MOCK_DISPATCHER_USERS.map((dispatcher) => [dispatcher.id, dispatcher.color]),
) as Record<string, string>;
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))
);
}

View file

@ -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-");
}

View file

@ -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-");
}

View file

@ -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<WorkOrderTableRow, "scheduledOn" | "dayGroup" | "dayLabel"> {
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);
}

View file

@ -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<string, Location> = {
"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: [],
};
}

View file

@ -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<string>,
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<string>,
(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<string>,
(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<string>,
(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";
}

View file

@ -0,0 +1,119 @@
import type { CompDocData, DocFields } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
export const SEAHAVEN_HEADER = {
name: "Seahaven Industries, Inc.",
address: "710 Koehler Avenue, Ronkonkoma, NY 11779",
phone: "(631) 776-5102",
fax: "(631) 677-3934",
email: "work-orders@seahavenind.com",
};
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function buildDocHtml(
row: Pick<WorkOrderTableRow, "woNumber" | "site" | "pm" | "tech" | "company">,
data: CompDocData,
fields: DocFields,
): string {
const cityStateZip =
[fields.city, fields.state].filter(Boolean).join(", ") + (fields.zip ? ` ${fields.zip}` : "");
const proc = procedureFor(row.pm);
const completedBlock = `<h3 style="margin:16px 0 4px;">Completion Details:</h3>
<p style="margin:2px 0;"><b>Date Work Completed:</b> ${esc(fields.dateCompleted || "—")}</p>
<p style="margin:2px 0;"><b>Amazon Representative:</b> ${esc(fields.amazonRep || "—")}</p>
<p style="margin:2px 0;"><b>Seahaven Representative:</b> ${esc(fields.seaRep || "—")}</p>`;
const procHtml = proc.steps
.map((st) => {
const sub = st.sub
? `<ol type="a" style="margin:4px 0 0 0;padding-left:22px;">${st.sub.map((x) => `<li style="margin:2px 0;">${esc(x)}</li>`).join("")}</ol>`
: "";
return `<li style="margin:6px 0;">${esc(st.text)}${sub}</li>`;
})
.join("");
const safety = SAFETY_BULLETS.map((b) => `<li style="margin:4px 0;">${esc(b)}</li>`).join("");
const notesBlock = data.notes
? `<h3 style="margin:16px 0 4px;">PM Comments:</h3><p style="margin:0;">${esc(data.notes)}</p>`
: "";
return `<!doctype html><html><head><meta charset="utf-8"><title>WO ${esc(row.woNumber)} — Completion</title>
<style>
@page { margin: 28px; }
* { box-sizing: border-box; }
body { font-family: 'DM Sans', system-ui, sans-serif; color: #161E38; font-size: 13px; line-height: 1.5; margin: 0; }
.band-top { height: 14px; background: linear-gradient(100deg,#27AAE1,#1C75BC); }
.band-bottom { height: 26px; margin-top: 28px; background: linear-gradient(100deg,#1C75BC,#27AAE1); clip-path: polygon(0 40%,100% 0,100% 100%,0 100%); }
.wrap { padding: 0 36px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; padding: 20px 0 12px; }
.brand { font-weight: 800; font-size: 22px; letter-spacing: .04em; color: #1C75BC; }
.brand small { display:block; font-size: 9px; letter-spacing: .22em; color: #58595B; font-weight:600; }
.co { text-align: right; font-size: 12px; color: #161E38; }
.co b { color:#161E38; }
.rule { height: 3px; background: linear-gradient(90deg,#27AAE1,#1C75BC); border-radius: 2px; }
h1 { font-size: 14px; letter-spacing: .04em; margin: 18px 0 4px; }
.wo { color: #1C75BC; font-weight: 700; }
.muted { color: #58595B; }
ol { padding-left: 22px; }
</style></head>
<body>
<div class="band-top"></div>
<div class="wrap">
<div class="head">
<div class="brand">SEAHAVEN<small>INDUSTRIES</small></div>
<div class="co">
<div><b>${esc(SEAHAVEN_HEADER.name)}</b></div>
<div>${esc(SEAHAVEN_HEADER.address)}</div>
<div>${esc(SEAHAVEN_HEADER.phone)}</div>
<div>${esc(SEAHAVEN_HEADER.fax)}</div>
<div>${esc(SEAHAVEN_HEADER.email)}</div>
</div>
</div>
<div class="rule"></div>
<h1>${esc(serviceTitle(row).toUpperCase())}</h1>
<p style="margin:2px 0;" class="wo">WO# ${esc(row.woNumber)}</p>
<p style="margin:14px 0 0;">Site ID — ${esc(row.site)}</p>
<p style="margin:2px 0;">Amazon.com Services LLC ${esc(row.site)}</p>
<p style="margin:2px 0;" class="muted">${esc(fields.street)},</p>
<p style="margin:2px 0;" class="muted">${esc(cityStateZip)}</p>
<p style="margin:14px 0 4px;"><b>Vendor / Technician:</b> ${esc(row.tech || "—")}${row.company ? ` &middot; ${esc(row.company)}` : ""}</p>
<ul style="padding-left:20px;">${safety}</ul>
${completedBlock}
<h3 style="margin:16px 0 4px;">${esc(proc.heading)}:</h3>
<p style="margin:0 0 8px;">${esc(proc.intro)}</p>
<ol>${procHtml}</ol>
${notesBlock}
</div>
<div class="band-bottom"></div>
</body></html>`;
}
export function printDoc(html: string): void {
const iframe = document.createElement("iframe");
iframe.style.position = "fixed";
iframe.style.right = "0";
iframe.style.bottom = "0";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.border = "0";
document.body.appendChild(iframe);
const idoc = iframe.contentWindow?.document;
if (!idoc) {
document.body.removeChild(iframe);
return;
}
idoc.open();
idoc.write(html);
idoc.close();
const win = iframe.contentWindow!;
setTimeout(() => {
win.focus();
win.print();
setTimeout(() => {
if (iframe.parentNode) document.body.removeChild(iframe);
}, 1200);
}, 300);
}

View file

@ -0,0 +1,99 @@
export interface ProcStep {
text: string;
sub?: string[];
}
export interface Procedure {
heading: string;
intro: string;
steps: ProcStep[];
}
export const SAFETY_BULLETS = [
"Use required PPE (gloves, goggles, hearing protection, arc flash equip. if needed).",
"Always follow OSHA, LOTO, FTHA, NFPA, and Amazon Policies before placing hands on equipment.",
'If any other issues are found not part of this PM\'s tasks please create a new "Work from PM" work order to make the correction.',
];
const BACKFLOW_PROCEDURE: Procedure = {
heading: "Backflow Procedure",
intro:
"Each backflow should be tested and if it passes inspection, the documentation must be submitted to the local municipality (i.e., city or county). No backflow PM is sufficient without at least testing the backflow.",
steps: [
{
text: "Contact base building coordinator/site POC, schedule backflow testing",
},
{ text: "Inform Facility Manager of scheduled test date" },
{
text: "Meet with base building coordinator/site POC upon arrival at site, confirming backflows will now be tested",
},
{
text: "Visually inspect backflows and take photos of units",
sub: [
"Document backflow location in facility",
'Document size and type of backflow (i.e., 1" RPZ backflow)',
],
},
{
text: "Perform backflow test",
sub: [
"If backflow test fails, disassemble backflow as needed and determine reason for failure",
"Make repairs to backflow",
"Retest backflow and ensure appropriate pressure",
],
},
{
text: "File backflow test and certification paperwork with site POC and local municipality",
},
{
text: 'If any failed backflow cannot be repaired immediately, notify the site POC and schedule tentative follow-up date for return, ensure a new PO for the follow up work. Issue a new "Work from PM" work order.',
sub: [
"Ensure equipment is functioning appropriately and safely before returning to operations",
"Ensure equipment and work area is clean prior to leaving the work site",
"If any deficiencies are discovered and unable to repair immediately, schedule follow up date with vendor and issue a new PO for the follow-up work. Issue a new \"work from PM' work order.",
"Provide adequate lead time for parts/scheduling",
],
},
],
};
const GENERIC_PROCEDURE: Procedure = {
heading: "Service Procedure",
intro:
"Perform the scheduled preventive maintenance per the manufacturer specification and Amazon site requirements. Document all findings and submit the completion paperwork to the site POC.",
steps: [
{
text: "Contact base building coordinator/site POC and schedule the service visit",
},
{ text: "Inform Facility Manager of the scheduled date" },
{
text: "Visually inspect the equipment and take photos of units",
sub: [
"Document equipment location in facility",
"Document make, model and condition of the asset",
],
},
{
text: "Perform the required preventive maintenance tasks for this asset",
sub: [
"If a deficiency is found, determine the corrective action and parts required",
"Make repairs where possible and re-test to confirm normal operation",
],
},
{
text: "Ensure equipment is functioning appropriately and the work area is clean prior to leaving the site",
},
{
text: "File the completion paperwork with the site POC and Seahaven",
sub: [
'If any deficiencies are discovered and unable to repair immediately, schedule a follow-up date with vendor and issue a new PO. Issue a new "Work from PM" work order.',
],
},
],
};
export function procedureFor(pm: string): Procedure {
return pm === "Backflow Preventers" || pm === "Domestic Backflow"
? BACKFLOW_PROCEDURE
: GENERIC_PROCEDURE;
}

View file

@ -0,0 +1,31 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const SERVICE_PHRASE: Record<string, string> = {
"Scrubber Pit": "Scrubber Pit Augering",
"Backflow Preventers": "Backflow Preventer Testing",
"Domestic Backflow": "Domestic Backflow Testing",
"Tank / Water Heater": "Tank / Water Heater Inspection",
"Pressure Reducing Valve": "Pressure Reducing Valve Service",
"Pumping Systems": "Pumping System Maintenance",
"HVAC Inspection": "HVAC Inspection",
Electrical: "Electrical Inspection",
Plumbing: "Plumbing Service",
"Door / Hardware": "Door / Hardware Service",
"Floor Drain Backing Up": "Floor Drain Augering",
"Parking Lot Lighting": "Parking Lot Lighting Service",
"Mens Restroom Camera Line": "Restroom Camera Line Service",
"Assess Window Leaks": "Window Leak Assessment",
};
const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
function hashNum(s: string, mod: number): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % mod;
}
export function serviceTitle(row: Pick<WorkOrderTableRow, "pm" | "woNumber">): string {
const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
}

View file

@ -0,0 +1,31 @@
import type { Location } from "@/domain/locations/types/location";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function buildLabel(city?: string, state?: string): string {
return [city, state].filter(Boolean).join(", ");
}
export function resolveCompDocSite(
row: Pick<WorkOrderTableRow, "site" | "location">,
location?: Location | null,
): CompDocSiteInfo {
if (location?.address || location?.city) {
const city = location.city ?? "";
const state = location.state ?? "";
const zip = location.zipCode ?? "";
const label = buildLabel(city, state);
const street = location.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
const locationParts = row.location.split(",").map((s) => s.trim());
const city = locationParts[0] ?? "";
const state = locationParts[1] ?? "";
const label = buildLabel(city, state);
const street = row.location && !label ? row.location : "";
const fullAddress = [street, label].filter(Boolean).join(", ") || "—";
return { street, city, state, zip: "", label, fullAddress };
}

View file

@ -0,0 +1,80 @@
import { CheckCircle2, FileText, XCircle } from "lucide-react";
import type { CompletionDocStateConfig, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export function docStateOf(status: CompletionDocStatus): DocState {
return status === "Yes" ? "generated" : status === "NN" ? "not-required" : "draft";
}
export function fmtDocNow(): string {
const d = new Date();
let h = d.getHours();
const ap = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()} · ${h}:${pad2(d.getMinutes())} ${ap}`;
}
export const DOC_STATE_CFG: Record<DocState, CompletionDocStateConfig> = {
draft: {
pillLabel: "Draft — not generated",
pillBg: "var(--color-bg-muted)",
pillText: "var(--muted-foreground)",
cardBg: "var(--color-bg-muted)",
cardBorder: "var(--color-border)",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--muted-foreground)",
cardTitle: "Draft — not generated",
icon: FileText,
},
generated: {
pillLabel: "Generated",
pillBg: "var(--doc-yes-bg)",
pillText: "var(--color-success)",
cardBg: "var(--doc-yes-bg)",
cardBorder: "#bbf7d0",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--color-success)",
cardTitle: "Generated",
icon: CheckCircle2,
},
"not-required": {
pillLabel: "Not required",
pillBg: "var(--indicator-co-1-bg)",
pillText: "var(--warning)",
cardBg: "var(--indicator-co-1-bg)",
cardBorder: "#fde68a",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--warning)",
cardTitle: "Not required",
icon: XCircle,
},
};
export function docStatusCardDescription(state: DocState, generatedAt: string): string {
if (state === "draft") {
return "This document hasn't been generated yet. Add any extra details, then generate it — or mark it as not required.";
}
if (state === "generated") {
return `Ready to download or share. Generated ${generatedAt}.`;
}
return "No document is required for this work order. You can undo this anytime.";
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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)}`;
}

View file

@ -0,0 +1,31 @@
import type {
FileCategory,
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
let mediaIdCounter = 0;
export function classifyUpload(f: File): {
fileType: FileCategory;
category: MediaCategory;
} {
const t = f.type;
if (t.startsWith("image/")) return { fileType: "image", category: "Photo" };
if (t.startsWith("video/")) return { fileType: "video", category: "Video" };
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
return { fileType: "other", category: "Other" };
}
export function makeMediaFile(f: File): MediaFile {
const { fileType, category } = classifyUpload(f);
return {
id: `m-${Date.now()}-${mediaIdCounter++}`,
name: f.name,
fileType,
url: URL.createObjectURL(f),
category,
};
}