Compare commits

...

5 commits

Author SHA1 Message Date
Arthur Bassi
af0e4aa1a6 feat(work-orders): add weekly board page shell and list chrome 2026-07-17 14:00:48 -03:00
Arthur Bassi
e446bbae0e test(work-orders): cover board filters and wizard mappers 2026-07-17 13:59:32 -03:00
Arthur Bassi
df8f3553e8 feat(work-orders): add board utils and mock data 2026-07-17 13:59:31 -03:00
Arthur Bassi
e13bc4c1a2 feat(work-orders): add domain core types, api, mappers and use-cases 2026-07-17 13:58:19 -03:00
Arthur Bassi
a3e8a533b1 fix(ui): align shared board plumbing with ky v2 and react-router
- api.ts: use ky v2 hook object args, prefix (not prefixUrl), and normalizeApiRequestError
- replace react-router-dom with react-router in layout, topbar, and vendor-pos view
- move MUI style props (justifyContent, alignItems, flexWrap, display, fontWeight) into sx
- resource-list-table/query-key: use local types instead of missing work-order-list module
2026-07-17 13:31:56 -03:00
61 changed files with 4743 additions and 1123 deletions

View file

@ -1,8 +1,8 @@
import ky, { type HTTPError, type KyInstance, type Options } from "ky";
import ky, { type KyInstance, type Options } from "ky";
import { env } from "@/lib/env";
import { resolveApiPrefixUrl } from "@/lib/resolve-api-prefix-url";
import { API_PATHS } from "@/api/api-paths";
import { createApiErrorFromResponse, mapHttpStatusToMessage } from "@/api/api-error";
import { mapHttpStatusToMessage, normalizeApiRequestError } from "@/api/api-error";
import { queryKeys } from "@/infra/query-key/query-key";
import queryClient from "@/lib/query/query-client";
import { isAuthBypassEnabled } from "@/lib/auth/auth-bypass";
@ -22,36 +22,23 @@ function isAuthLoginRequest(request: Request): boolean {
}
export const api: KyInstance = ky.create({
prefixUrl: resolveApiPrefixUrl(env.apiUrl),
prefix: resolveApiPrefixUrl(env.apiUrl),
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
hooks: {
beforeRequest: [
(request) => {
({ request }) => {
const token = getAccessToken();
if (token) {
request.headers.set("Authorization", token);
}
},
],
beforeError: [
async (error: HTTPError) => {
const { response } = error;
if (!response) {
error.message = "No response from server. Please check your connection.";
return error;
}
const apiError = await createApiErrorFromResponse(response);
error.message = apiError.message;
return error;
},
],
beforeError: [({ error }) => normalizeApiRequestError(error)],
afterResponse: [
async (request, _options, response) => {
({ request, response }) => {
if (response.status !== 401 || isAuthLoginRequest(request) || isAuthBypassEnabled()) {
return response;
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { Outlet } from "react-router";
import { Box } from "@mui/material";
import { ProtectedRoute } from "@/components/auth/protected-route";

View file

@ -1,5 +1,5 @@
import { useState, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router";
import {
Alert,
Box,
@ -33,7 +33,7 @@ function formatMoney(value: number | null | undefined): string {
function InfoRow({ label, children }: { label: string; children: ReactNode }) {
return (
<Box>
<Typography variant="caption" color="text.secondary" display="block">
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2">
@ -107,7 +107,11 @@ export default function VendorPoViewPage() {
return (
<Box className="mx-auto max-w-5xl p-4">
<Stack direction="row" justifyContent="space-between" alignItems="center" className="mb-4">
<Stack
direction="row"
className="mb-4"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Button variant="text" onClick={() => navigate(-1)}>
← Back
</Button>
@ -118,7 +122,7 @@ export default function VendorPoViewPage() {
<Stack spacing={1} className="mb-4">
<Typography variant="h5">{dispatch.poNumber || "(no PO #)"}</Typography>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">
Dispatch {dispatch.dispatchNumber || "—"}
</Typography>
@ -256,7 +260,10 @@ export default function VendorPoViewPage() {
<Stack spacing={2}>
{dispatch.upliftRequests.map((u) => (
<Box key={String(u.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Stack
direction="row"
sx={{ justifyContent: "space-between", alignItems: "center" }}
>
<Typography variant="body2" className="font-semibold">
{formatMoney(u.currentNTE)} → {formatMoney(u.requestedNTE)}
<Typography

View file

@ -1,36 +1,20 @@
import { useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import { Link as RouterLink, useNavigate } from "react-router";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Breadcrumbs,
Button,
Chip,
CircularProgress,
Link,
Menu,
MenuItem,
Paper,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useState } from "react";
import { useParams } from "react-router";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections";
import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
@ -38,164 +22,20 @@ import {
useChangeWorkOrderStatus,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { formatDateTime, timeSince, waitTimeColor } from "@/lib/time-utils";
const STATUS_COLORS: Record<
string,
"default" | "primary" | "secondary" | "error" | "info" | "success" | "warning"
> = {
Open: "primary",
"In Progress": "info",
"On Hold": "warning",
Done: "success",
Cancelled: "default",
};
type ActivityTab = "customer" | "internal" | "audit";
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
[...comments].sort(
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
),
[comments],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No comments yet.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
);
}
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No changes recorded.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Stack>
);
}
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const customerComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "customer"),
[wo?.comments],
);
const internalComments = useMemo(
() => (wo?.comments ?? []).filter((comment) => comment.commentType === "internal"),
[wo?.comments],
);
const auditLog = wo?.auditLog ?? [];
const handleCommentSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!commentText.trim()) {
return;
}
commentMutation.mutate(
{ text: commentText.trim(), commentType: activeTab },
{ onSuccess: () => setCommentText("") },
);
};
if (isLoading) {
return (
@ -221,14 +61,9 @@ export default function WorkOrderViewPage() {
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
<Typography color="text.primary">{wo.internalWONumber}</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
@ -241,41 +76,13 @@ export default function WorkOrderViewPage() {
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => setStatusAnchor(event.currentTarget)}
className="w-fit cursor-pointer"
<WorkOrderStatusMenu
status={wo.status}
onStatusChange={(status) => statusMutation.mutate(status)}
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => setStatusAnchor(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
setStatusAnchor(null);
if (status !== wo.status) {
statusMutation.mutate(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
Dispatch
</Button>
@ -287,283 +94,27 @@ export default function WorkOrderViewPage() {
</Button>
</Stack>
</Stack>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Internal WO" value={wo.internalWONumber} />
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
<Field label="Priority" value={wo.priority || "—"} />
<Field label="Location" value={wo.location || "—"} />
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
Assigned To
</Typography>
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => setAssignAnchor(null)}
>
<MenuItem
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: "", userName: "" });
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: user.id, userName: user.name });
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
<Field label="Source" value={wo.source || "—"} />
<Field label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Classification
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="Problem" value={wo.problem || "—"} />
<Field label="Trade" value={wo.trade || "—"} />
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Schedule
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<Field label="Due Date" value={wo.dueDate || "—"} />
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
<Field label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Financial
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<Field label="Customer PO" value={wo.po || "—"} />
<Field label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Dispatches ({wo.dispatches.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
</TableRow>
</TableHead>
<TableBody>
{wo.dispatches.map((dispatch) => {
const waitColor = waitTimeColor(dispatch.dispatchedAt);
return (
<TableRow
key={String(dispatch.id)}
hover
className="cursor-pointer"
onClick={() => setDispatchDetailId(dispatch.id)}
>
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
<TableCell>{dispatch.vendorName}</TableCell>
<TableCell>
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
</TableCell>
<TableCell>{dispatch.status}</TableCell>
<TableCell>
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
</TableCell>
<TableCell>
{dispatch.status === "Sent" ? (
<Typography
variant="caption"
className={
waitColor === "green"
? "text-green-600"
: waitColor === "yellow"
? "text-yellow-600"
: "text-red-600"
}
>
{timeSince(dispatch.dispatchedAt)}
</Typography>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography
sx={{
color: "text.secondary",
}}
>
No dispatches yet.
</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Description
</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
<WorkOrderDetailsSection
wo={wo}
users={users}
onAssign={(userId, userName) => assignmentMutation.mutate({ userId, userName })}
onDispatchClick={setDispatchDetailId}
onCommentSubmit={(text, commentType) => commentMutation.mutate({ text, commentType })}
isCommentPending={commentMutation.isPending}
/>
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={commentMutation.isPending}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={commentMutation.isPending || !commentText.trim()}
>
{commentMutation.isPending ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
{dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
<DispatchCreateModal workOrder={wo} onClose={() => setDispatchOpen(false)} />
)}
{dispatchDetailId && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
</Box>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
{label}
</Typography>
<Typography variant="body2">{value}</Typography>
</Box>
);
}

View file

@ -0,0 +1,168 @@
import { useMemo } from "react";
import { Popover } from "@mui/material";
import { ChevronDown, UserX, Users } from "lucide-react";
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
MOCK_DISPATCHER_USERS,
getDispatcherAvatarColor,
} from "@/domain/work-orders/mocks/dispatcher-filter-mock-data";
import { CheckboxMenuItem } from "@/components/ui/checkbox-menu-item";
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
type DispatcherFilterProps = {
selectedIds: Set<string>;
onChange: (ids: Set<string>) => void;
users: WorkOrderUser[];
currentUserId?: string | number;
};
function userInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
function DispatcherAvatar({ name, color }: { name: string; color: string }) {
return (
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
style={{ background: `${color}25`, color }}
>
{userInitials(name)}
</span>
);
}
export function DispatcherFilter({
selectedIds,
onChange,
users,
currentUserId,
}: DispatcherFilterProps) {
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
const meId = currentUserId != null && currentUserId !== "" ? String(currentUserId) : null;
const allSelected = selectedIds.size === 0;
const meOnly = meId != null && selectedIds.size === 1 && selectedIds.has(meId);
const unassignedOnly = selectedIds.size === 1 && selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED);
const label = useMemo(() => {
if (allSelected) return "All dispatchers";
if (meOnly) return "My WOs";
if (unassignedOnly) return "Unassigned";
return `${selectedIds.size} selected`;
}, [allSelected, meOnly, unassignedOnly, selectedIds.size]);
const dispatchers = useMemo(() => {
const byId = new Map<string, WorkOrderUser>();
for (const user of [...MOCK_DISPATCHER_USERS, ...users]) {
byId.set(String(user.id), user);
}
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [users]);
const toggle = (id: string) => {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
onChange(next);
};
return (
<>
<MenuAnchorButton
onAnchor={setAnchor}
open={open}
className="flex w-auto shrink-0 items-center gap-2 border whitespace-nowrap transition-colors"
style={
!allSelected
? {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--color-primary-tint)",
borderColor: "#bfe2f5",
color: "var(--primary)",
}
: {
height: 38,
padding: "0 12px",
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
background: "var(--card)",
borderColor: "var(--border)",
color: "var(--foreground)",
}
}
aria-haspopup="listbox"
>
<Users size={15} style={{ color: "var(--muted-foreground)" }} />
{label}
<ChevronDown size={14} style={{ color: "var(--muted-foreground)" }} />
</MenuAnchorButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{
paper: {
className: "mt-1 w-[220px] rounded-lg border border-border p-1.5 shadow-md",
},
}}
>
<CheckboxMenuItem
checked={allSelected}
label="All dispatchers"
onClick={() => onChange(new Set())}
/>
{meId && (
<CheckboxMenuItem
checked={meOnly}
label="My WOs only"
labelClassName="font-medium text-primary"
onClick={() => onChange(new Set([meId]))}
/>
)}
<CheckboxMenuItem
checked={selectedIds.has(ASSIGNEE_FILTER_UNASSIGNED)}
label="Unassigned"
left={<UserX size={14} className="shrink-0 text-muted-foreground" />}
className="mb-1 pb-2"
style={{ borderBottom: "1px solid var(--color-border)" }}
onClick={() => toggle(ASSIGNEE_FILTER_UNASSIGNED)}
/>
{dispatchers.map((user) => {
const id = String(user.id);
const isMe = meId === id;
return (
<CheckboxMenuItem
key={id}
checked={selectedIds.has(id)}
label={user.name}
left={<DispatcherAvatar name={user.name} color={getDispatcherAvatarColor(user)} />}
right={
isMe ? <span className="text-[10px] text-muted-foreground">me</span> : undefined
}
onClick={() => toggle(id)}
/>
);
})}
</Popover>
</>
);
}

View file

@ -0,0 +1,131 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { NavArrowButton } from "@/components/ui/nav-arrow-button";
import { NavPillButton } from "@/components/ui/nav-pill-button";
import {
buildWeekWindow,
isFutureWeek,
isLiveWeek,
} from "@/domain/work-orders/utils/list-week-utils";
type WeekNavProps = {
weekMonday: string;
onChange: (mondayIso: string) => void;
};
const VISIBLE_COUNT = 3;
const WINDOW_BEFORE = 12;
const WINDOW_AFTER = 1;
function WeekLiveBadge() {
return (
<span
className="uppercase"
style={{
fontFamily: "var(--font-mono)",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
background: "var(--success)",
borderRadius: 9999,
padding: "1px 5px",
letterSpacing: "0.04em",
}}
>
Live
</span>
);
}
export function WeekNav({ weekMonday, onChange }: WeekNavProps) {
const weeks = useMemo(
() => buildWeekWindow(weekMonday, WINDOW_BEFORE, WINDOW_AFTER),
[weekMonday],
);
const activeIndex = useMemo(
() => weeks.findIndex((week) => week.mondayIso === weekMonday),
[weeks, weekMonday],
);
const [offset, setOffset] = useState(() => Math.max(0, activeIndex >= 0 ? activeIndex - 1 : 0));
useEffect(() => {
if (activeIndex < 0) return;
const maxOffset = Math.max(0, weeks.length - VISIBLE_COUNT);
setOffset((current) => {
if (activeIndex < current) return Math.min(activeIndex, maxOffset);
if (activeIndex > current + VISIBLE_COUNT - 1) {
return Math.min(Math.max(0, activeIndex - (VISIBLE_COUNT - 1)), maxOffset);
}
return current;
});
}, [activeIndex, weeks.length]);
const visible = weeks.slice(offset, offset + VISIBLE_COUNT);
const canGoBack = offset > 0;
const canGoForward = offset + VISIBLE_COUNT < weeks.length;
return (
<div className="flex shrink-0 items-center gap-1.5">
<NavArrowButton
disabled={!canGoBack}
onClick={() => setOffset((current) => current - 1)}
aria-label="Previous weeks"
>
<ChevronLeft size={15} />
</NavArrowButton>
{visible.map((week) => {
const isActive = weekMonday === week.mondayIso;
const isDisabled = isFutureWeek(week.mondayIso);
const showLiveBadge = isLiveWeek(week.mondayIso);
return (
<NavPillButton
key={week.mondayIso}
active={isActive}
disabled={isDisabled}
onClick={() => onChange(week.mondayIso)}
>
{week.label}
{showLiveBadge && <WeekLiveBadge />}
</NavPillButton>
);
})}
<NavArrowButton
disabled={!canGoForward}
onClick={() => setOffset((current) => current + 1)}
aria-label="Next weeks"
>
<ChevronRight size={15} />
</NavArrowButton>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { SegmentButton, SegmentGroup } from "@/components/ui/segment-control";
import { WO_TYPES } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
type WoTypeSegmentProps = {
value: WorkOrderTypeFilter;
onChange: (value: WorkOrderTypeFilter) => void;
};
const OPTIONS: WorkOrderTypeFilter[] = ["All", ...WO_TYPES];
export function WoTypeSegment({ value, onChange }: WoTypeSegmentProps) {
return (
<SegmentGroup>
{OPTIONS.map((option) => (
<SegmentButton key={option} active={value === option} onClick={() => onChange(option)}>
{option}
</SegmentButton>
))}
</SegmentGroup>
);
}

View file

@ -0,0 +1,48 @@
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import type { WorkOrderTypeFilter } from "@/domain/work-orders/types/work-order-list-filters";
import { DispatcherFilter } from "@/app/(protected)/workorders/_components/list/filter-bar/dispatcher-filter";
import { WeekNav } from "@/app/(protected)/workorders/_components/list/filter-bar/week-nav";
import { WoTypeSegment } from "@/app/(protected)/workorders/_components/list/filter-bar/wo-type-segment";
type WorkOrdersFilterBarProps = {
weekMonday: string;
onWeekChange: (mondayIso: string) => void;
dispatcherIds: Set<string>;
onDispatcherChange: (ids: Set<string>) => void;
typeFilter: WorkOrderTypeFilter;
onTypeChange: (value: WorkOrderTypeFilter) => void;
users: WorkOrderUser[];
currentUserId?: string | number;
filteredCount: number;
scopeTotalCount: number;
};
export function WorkOrdersFilterBar({
weekMonday,
onWeekChange,
dispatcherIds,
onDispatcherChange,
typeFilter,
onTypeChange,
users,
currentUserId,
filteredCount,
scopeTotalCount,
}: WorkOrdersFilterBarProps) {
const resultLabel = `${filteredCount} of ${scopeTotalCount}`;
return (
<div className="-mx-2.5 flex h-14 shrink-0 items-center gap-3 border-b border-(--color-border) bg-(--color-bg-surface) px-4">
<DispatcherFilter
selectedIds={dispatcherIds}
onChange={onDispatcherChange}
users={users}
currentUserId={currentUserId}
/>
<WeekNav weekMonday={weekMonday} onChange={onWeekChange} />
<WoTypeSegment value={typeFilter} onChange={onTypeChange} />
<div className="flex-1" />
<span className="shrink-0 text-xs font-medium text-muted-foreground">{resultLabel}</span>
</div>
);
}

View file

@ -0,0 +1,53 @@
import { AppliedFilterChip } from "@/components/ui/applied-filter-chip";
import { AppliedFiltersBar } from "@/components/ui/applied-filters-bar";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
import {
buildAdvancedFilterChips,
countAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
type WorkOrdersAdvancedFiltersBannerProps = {
applied: WorkOrderAdvancedFilters;
totalCount: number;
sites: { id: string | number; name: string }[];
users: WorkOrderUser[];
onClearChip: (chipKey: string) => void;
onClearAll: () => void;
};
export function WorkOrdersAdvancedFiltersBanner({
applied,
totalCount,
sites,
users,
onClearChip,
onClearAll,
}: WorkOrdersAdvancedFiltersBannerProps) {
if (countAdvancedFilters(applied) === 0) {
return null;
}
const siteNameOf = (id: string) => sites.find((site) => String(site.id) === id)?.name ?? id;
const dispatcherNameOf = (id: string) => users.find((user) => String(user.id) === id)?.name ?? id;
const chips = buildAdvancedFilterChips(applied, { siteNameOf, dispatcherNameOf });
return (
<AppliedFiltersBar
countLabel={`${totalCount} work order${totalCount !== 1 ? "s" : ""}`}
onClearAll={onClearAll}
>
{chips.map((chip) => (
<AppliedFilterChip
key={chip.key}
group={chip.group}
value={chip.value}
count={chip.count}
accent={chip.accent}
onDismiss={() => onClearChip(chip.key)}
/>
))}
</AppliedFiltersBar>
);
}

View file

@ -0,0 +1,63 @@
import { CircularProgress } from "@mui/material";
import { Plus, SlidersHorizontal } from "lucide-react";
import { SearchInput } from "@/components/ui/search-input";
import { ToolbarButton } from "@/components/ui/toolbar-button";
type WorkOrdersListHeaderProps = {
search: string;
onSearchChange: (value: string) => void;
onOpenAdvancedFilters: () => void;
onNewWorkOrder: () => void;
activeFilterCount: number;
isFetching?: boolean;
};
export function WorkOrdersListHeader({
search,
onSearchChange,
onOpenAdvancedFilters,
onNewWorkOrder,
activeFilterCount,
isFetching = false,
}: WorkOrdersListHeaderProps) {
return (
<>
<SearchInput
value={search}
onChange={onSearchChange}
placeholder="Search this week…"
aria-label="Search work orders"
/>
<ToolbarButton
variant="outline"
icon={<SlidersHorizontal size={13} />}
onClick={onOpenAdvancedFilters}
aria-label="Advanced Filters"
title="Advanced Filters"
badge={activeFilterCount}
>
Filters
</ToolbarButton>
<ToolbarButton variant="primary" icon={<Plus size={13} />} onClick={onNewWorkOrder}>
New WO
</ToolbarButton>
{isFetching && <CircularProgress size={14} />}
</>
);
}

View file

@ -0,0 +1,117 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { Button, IconButton, Tooltip } from "@mui/material";
import { ResourceListTable, type ResourceListColumn } from "@/components/ui/resource-list-table";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
import type { SortDirection } from "@/domain/work-orders/types/work-order-list";
import { formatDateTime } from "@/lib/time-utils";
function displayDateTime(value: string): string {
return formatDateTime(value) || "—";
}
type WorkOrdersTableProps = {
rows: WorkOrderListItem[];
isLoading: boolean;
sort: { column: string; direction: SortDirection };
onSort: (column: string) => void;
onView: (id: string | number) => void;
onEdit: (id: string | number) => void;
};
export function WorkOrdersTable({
rows,
isLoading,
sort,
onSort,
onView,
onEdit,
}: WorkOrdersTableProps) {
const columns: ResourceListColumn<WorkOrderListItem>[] = [
{
id: "number",
header: "WO #",
sortable: true,
render: (row) => (
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onView(row.id)}
>
{row.number || row.id}
</Button>
),
},
{
id: "title",
header: "Title",
sortable: true,
render: (row) => row.title,
},
{
id: "location",
header: "Location",
render: (row) => row.location,
},
{
id: "priority",
header: "Priority",
render: (row) => row.priority,
},
{
id: "status",
header: "Status",
render: (row) => <WorkOrderStatusBadge status={row.status || "Open"} />,
},
{
id: "createdDate",
header: "Created",
cellClassName: "whitespace-nowrap",
render: (row) => displayDateTime(row.createdDate),
},
{
id: "assignedTo",
header: "Assigned To",
render: (row) => row.assignedTo || "Unassigned",
},
{
id: "lastUpdated",
header: "Last Updated",
cellClassName: "whitespace-nowrap",
render: (row) => displayDateTime(row.lastUpdated),
},
{
id: "actions",
header: "Actions",
align: "right",
render: (row) => (
<>
<Tooltip title="View">
<IconButton size="small" aria-label="View work order" onClick={() => onView(row.id)}>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit work order" onClick={() => onEdit(row.id)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</>
),
},
];
return (
<ResourceListTable
columns={columns}
rows={rows}
rowKey={(row) => String(row.id)}
isLoading={isLoading}
emptyMessage="No work orders found"
sort={sort}
onSort={onSort}
/>
);
}

View file

@ -0,0 +1,65 @@
import { type MouseEvent } from "react";
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
import { Image as ImageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { MediaUploader } from "./media-uploader";
type MediaUploadModalProps = {
row: WorkOrderTableRow;
open: boolean;
onOpenChange: (open: boolean) => void;
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploadModal({
row,
open,
onOpenChange,
uploads,
onChange,
}: MediaUploadModalProps) {
return (
<Dialog
open={open}
onClose={() => onOpenChange(false)}
maxWidth={false}
slotProps={{
...dialogBlurBackdropSlotProps,
paper: {
style: { borderRadius: 12, maxWidth: 520, width: "100%" },
onClick: (e: MouseEvent<HTMLDivElement>) => e.stopPropagation(),
},
}}
>
<DialogContent className="p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<ImageIcon size={16} className="shrink-0" style={{ color: "var(--foreground)" }} />
<DialogTitle className="p-0" style={{ fontSize: 15, color: "var(--color-text-primary)" }}>
Photos &amp; Videos
</DialogTitle>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>#{row.woNumber}</span>
</div>
<p className="sr-only">Upload photos and videos for this work order.</p>
<div className="mt-4">
<MediaUploader uploads={uploads} onChange={onChange} />
</div>
<div className="mt-2 flex justify-end">
<Button
variant="outline"
size="default"
className="text-[12px]"
onClick={() => onOpenChange(false)}
>
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,143 @@
import { useRef } from "react";
import {
File as FileIcon,
FileText,
Film,
Image as ImageIcon,
Paperclip,
Play,
Upload,
X,
} from "lucide-react";
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
image: ImageIcon,
video: Film,
pdf: FileText,
doc: FileIcon,
other: Paperclip,
};
type MediaUploaderProps = {
uploads: MediaFile[];
onChange: (next: MediaFile[]) => void;
};
export function MediaUploader({ uploads, onChange }: MediaUploaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const addFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
onChange([...uploads, ...Array.from(files).map(makeMediaFile)]);
};
const remove = (id: string) => onChange(uploads.filter((u) => u.id !== id));
return (
<div>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
addFiles(e.dataTransfer.files);
}}
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)]"
style={{
minHeight: 120,
border: "1px dashed var(--color-border)",
borderRadius: 8,
}}
>
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
Drag files here or click to browse
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
Accepted: JPG, PNG, MP4, MOV · Max 50MB per file
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/*,video/*"
multiple
hidden
onChange={(e) => addFiles(e.target.files)}
/>
{uploads.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{uploads.map((u) => {
const TypeIcon = FILETYPE_ICON[u.fileType];
return (
<div
key={u.id}
className="overflow-hidden rounded-lg border"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="relative"
style={{
aspectRatio: "1 / 1",
background: "var(--color-bg-muted)",
}}
>
{u.fileType === "image" ? (
<img
src={u.url}
alt={u.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : u.fileType === "video" ? (
<>
<video
src={u.url}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
<span className="absolute inset-0 flex items-center justify-center">
<Play size={18} style={{ color: "#fff" }} />
</span>
</>
) : (
<span className="absolute inset-0 flex items-center justify-center">
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
</span>
)}
<button
type="button"
onClick={() => remove(u.id)}
title="Remove"
className="absolute flex items-center justify-center transition-opacity hover:opacity-80"
style={{
top: 4,
right: 4,
width: 18,
height: 18,
borderRadius: 4,
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
}}
>
<X size={11} />
</button>
</div>
<div className="px-1 py-1">
<div
className="truncate"
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
>
{u.name}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,191 @@
import { useMemo, useState } from "react";
import { useDebounce } from "@/hooks/use-debounce";
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list";
import { WORK_ORDERS_TABLE_PAGE_SIZE } from "@/domain/work-orders/types/work-order-table-row";
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
import type {
WorkOrderBarFilters,
WorkOrderTypeFilter,
} from "@/domain/work-orders/types/work-order-list-filters";
import {
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
getInitialAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import {
compactWeekLabel,
currentWeekMonday,
weekRange,
} from "@/domain/work-orders/utils/list-week-utils";
function defaultDispatcherIds(currentUserId?: string | number): Set<string> {
if (isWoTableMockEnabled()) {
return new Set();
}
if (currentUserId != null && currentUserId !== "") {
return new Set([String(currentUserId)]);
}
return new Set();
}
/** Phase 2: map bar filters to API list params. */
export function toListParamsFromBar(
bar: WorkOrderBarFilters,
): Pick<WorkOrdersListParams, "dateFrom" | "dateTo" | "type" | "assigneeIds"> {
const [dateFrom, dateTo] = weekRange(bar.weekMonday);
return {
dateFrom,
dateTo,
type: bar.type === "All" ? undefined : bar.type,
assigneeIds: bar.dispatcherIds.size > 0 ? [...bar.dispatcherIds].join(",") : undefined,
};
}
export function useWorkOrdersListFilters(currentUserId?: string | number) {
const [search, setSearch] = useState("");
const [advOpen, setAdvOpen] = useState(false);
const [advApplied, setAdvApplied] = useState<WorkOrderAdvancedFilters | null>(null);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const [weekMonday, setWeekMondayState] = useState(currentWeekMonday);
const [dispatcherIds, setDispatcherIdsState] = useState<Set<string>>(() =>
defaultDispatcherIds(currentUserId),
);
const [typeFilter, setTypeFilterState] = useState<WorkOrderTypeFilter>("All");
const debouncedSearch = useDebounce(search, 300);
const barFilters = useMemo<WorkOrderBarFilters>(
() => ({
weekMonday,
dispatcherIds,
type: typeFilter,
}),
[weekMonday, dispatcherIds, typeFilter],
);
const weekLabel = useMemo(() => compactWeekLabel(weekMonday), [weekMonday]);
const scopeListParams = useMemo<WorkOrdersListParams>(
() => ({
page: 1,
pageSize: 1,
...toListParamsFromBar(barFilters),
}),
[barFilters],
);
const listParams = useMemo<WorkOrdersListParams>(
() => ({
search: debouncedSearch,
page: 1,
pageSize: WORK_ORDERS_TABLE_PAGE_SIZE,
sortBy,
sortDir,
...toListParamsFromBar(barFilters),
}),
[debouncedSearch, sortBy, sortDir, barFilters],
);
const activeFilterCount = useMemo(
() => (advApplied ? countAdvancedFilters(advApplied) : 0),
[advApplied],
);
const setWeekMonday = (value: string) => {
setWeekMondayState(value);
setPage(0);
};
const setDispatcherIds = (value: Set<string>) => {
setDispatcherIdsState(new Set(value));
setPage(0);
};
const setTypeFilter = (value: WorkOrderTypeFilter) => {
setTypeFilterState(value);
setPage(0);
};
const handleSearchChange = (value: string) => {
setSearch(value);
setPage(0);
};
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
setAdvApplied(cloneAdvancedFilters(filters));
setPage(0);
};
const clearAdvancedFilters = () => {
setAdvApplied(null);
setPage(0);
};
const clearAdvancedFilterChip = (chipKey: string) => {
if (!advApplied) return;
const next = clearAdvancedFilterGroup(advApplied, chipKey);
setAdvApplied(next);
setPage(0);
};
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => {
const next = current === "asc" ? "desc" : current === "desc" ? "" : "asc";
if (next === "") {
setSortBy("");
}
return next;
});
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
return {
search,
advOpen,
advApplied,
advInitial: getInitialAdvancedFilters(advApplied),
page,
pageSize,
sortBy,
sortDir,
weekMonday,
dispatcherIds,
typeFilter,
barFilters,
weekLabel,
listParams,
scopeListParams,
activeFilterCount,
handleSearchChange,
applyAdvancedFilters,
clearAdvancedFilters,
clearAdvancedFilterChip,
handleSort,
sortIndicator,
setAdvOpen,
setPage,
setPageSize,
setWeekMonday,
setDispatcherIds,
setTypeFilter,
setSortBy,
setSortDir,
};
}

View file

@ -1,339 +1,191 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
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";
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useDebounce } from "@/hooks/use-debounce";
import type { WorkOrderListItem } from "@/domain/work-orders/types/work-order";
import {
useWorkOrderUsers,
useWorkOrdersList,
} from "@/domain/work-orders/use-cases/use-work-orders-list";
const STATUS_OPTIONS = ["Open", "In Progress", "On Hold", "Done", "Cancelled"] as const;
function formatDateTime(value: string): string {
if (!value) {
return "—";
}
const normalized = value.endsWith("Z") ? value : `${value}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return value;
}
const datePart = date.toLocaleDateString("en-CA");
const timePart = date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const tz = date.toLocaleTimeString(undefined, { timeZoneName: "short" }).split(" ").pop();
return `${datePart} ${timePart} ${tz ?? ""}`.trim();
}
useWorkOrderLocations,
useWorkOrderVendors,
} from "@/domain/work-orders/use-cases/use-work-order-detail";
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";
import { useAuthContext } from "@/providers/auth-context";
export default function WorkOrdersListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [assigneeFilter, setAssigneeFilter] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
const { user } = useAuthContext();
const [wizardOpen, setWizardOpen] = useState(false);
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
const [slideOverEdit, setSlideOverEdit] = useState(false);
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
const filters = useWorkOrdersListFilters(user?.id);
const tableData = useWorkOrdersTableData(filters);
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
const debouncedSearch = useDebounce(search, 300);
const { data: users = [] } = useWorkOrderUsers();
const { data: locations = [] } = useWorkOrderLocations();
const { data: vendors = [] } = useWorkOrderVendors();
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
sortBy,
sortDir,
status: statusFilter,
assignee: assigneeFilter,
}),
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
const pmTypes = useMemo(
() =>
isWoTableMockEnabled()
? getMockPmTypes()
: problemOptions.map((option) => option.value).filter(Boolean),
[problemOptions],
);
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
const { data: users = [] } = useWorkOrderUsers();
const vendorTechs = useMemo(
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
[vendors],
);
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const totalCount = tableData.totalCount;
const scopeTotalCount = tableData.scopeTotalCount;
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
if (sortDir === "desc") {
setSortBy("");
}
return;
}
const activeDocRow = useMemo(() => {
if (!docRow) return null;
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
}, [docRow, tableData.rows]);
setSortBy(column);
setSortDir("asc");
};
const activeMediaRow = useMemo(() => {
if (!mediaRow) return null;
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
}, [mediaRow, tableData.rows]);
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
const handleStatusFilter = (status: string) => {
setStatusFilter((current) => (current === status ? "" : status));
setPage(0);
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
setSlideOverRow(row);
setSlideOverEdit(edit);
};
return (
<Box className="flex flex-col gap-4 p-4">
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Typography variant="h5" component="h1">
Work Orders
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<TextField
size="small"
value={search}
onChange={(event) => {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search..."
className="min-w-[220px]"
<Box className="flex h-full flex-col overflow-hidden p-2.5">
<PageHeader
surface
eyebrow="OPERATIONS"
title="Work Orders"
actions={
<WorkOrdersListHeader
search={filters.search}
onSearchChange={filters.handleSearchChange}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
onNewWorkOrder={() => setWizardOpen(true)}
activeFilterCount={filters.activeFilterCount}
isFetching={tableData.isFetching && !tableData.isLoading}
/>
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
+ New Work Order
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "center",
}
/>
<WorkOrdersFilterBar
weekMonday={filters.weekMonday}
onWeekChange={filters.setWeekMonday}
dispatcherIds={filters.dispatcherIds}
onDispatcherChange={filters.setDispatcherIds}
typeFilter={filters.typeFilter}
onTypeChange={filters.setTypeFilter}
users={users}
currentUserId={user?.id}
filteredCount={totalCount}
scopeTotalCount={scopeTotalCount}
/>
{filters.advApplied && (
<WorkOrdersAdvancedFiltersBanner
applied={filters.advApplied}
totalCount={totalCount}
sites={locations}
users={users}
onClearChip={filters.clearAdvancedFilterChip}
onClearAll={filters.clearAdvancedFilters}
/>
)}
<WoTable
tableData={tableData}
users={users}
sites={locations}
vendors={vendors}
pmTypes={pmTypes}
onOpenSlideOver={handleOpenSlideOver}
onOpenCompDoc={setDocRow}
onOpenMedia={setMediaRow}
onClearSearch={() => filters.handleSearchChange("")}
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
/>
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
<WorkOrdersAdvancedSearchSheet
open={filters.advOpen}
onOpenChange={filters.setAdvOpen}
initial={filters.advInitial}
onApply={filters.applyAdvancedFilters}
onClearAll={filters.clearAdvancedFilters}
sites={locations}
users={users}
pmTypes={pmTypes}
vendorTechs={vendorTechs}
/>
<WorkOrderSlideOver
row={slideOverRow}
editMode={slideOverEdit}
users={users}
onClose={() => {
setSlideOverRow(null);
setSlideOverEdit(false);
tableData.setSelectedId(null);
}}
>
<Chip
label="All"
clickable
color={statusFilter === "" ? "primary" : "default"}
variant={statusFilter === "" ? "filled" : "outlined"}
onClick={() => {
setStatusFilter("");
setPage(0);
/>
{activeMediaRow && (
<MediaUploadModal
row={activeMediaRow}
open={!!activeMediaRow}
onOpenChange={(open) => {
if (!open) setMediaRow(null);
}}
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
onChange={(files) => {
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
patchField(activeMediaRow.id, { mediaCount: files.length });
}}
/>
{STATUS_OPTIONS.map((status) => (
<Chip
key={status}
label={status}
clickable
color={statusFilter === status ? "primary" : "default"}
variant={statusFilter === status ? "filled" : "outlined"}
onClick={() => handleStatusFilter(status)}
/>
))}
<FormControl size="small" className="min-w-[180px]">
<InputLabel id="assignee-filter-label">Assignee</InputLabel>
<Select
labelId="assignee-filter-label"
label="Assignee"
value={assigneeFilter}
onChange={(event) => {
setAssigneeFilter(event.target.value);
setPage(0);
}}
>
<MenuItem value="">All Assignees</MenuItem>
<MenuItem value="__unassigned">Unassigned</MenuItem>
{users.map((user) => (
<MenuItem key={String(user.id)} value={user.name}>
{user.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{error && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load work orders"}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell
className="cursor-pointer whitespace-nowrap font-semibold"
onClick={() => handleSort("number")}
>
WO #{sortIndicator("number")}
</TableCell>
<TableCell
className="cursor-pointer font-semibold"
onClick={() => handleSort("title")}
>
Title{sortIndicator("title")}
</TableCell>
<TableCell className="font-semibold">Location</TableCell>
<TableCell className="font-semibold">Priority</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell className="font-semibold">Created</TableCell>
<TableCell className="font-semibold">Assigned To</TableCell>
<TableCell className="font-semibold">Last Updated</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" className="py-10">
<Typography
sx={{
color: "text.secondary",
}}
>
No work orders found
</Typography>
</TableCell>
</TableRow>
) : (
rows.map((row: WorkOrderListItem) => (
<TableRow key={String(row.id)} hover>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => navigate(`/workorders/${row.id}`)}
>
{row.number || row.id}
</Button>
</TableCell>
<TableCell>{row.title}</TableCell>
<TableCell>{row.location}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<WorkOrderStatusBadge status={row.status || "Open"} />
</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.createdDate)}
</TableCell>
<TableCell>{row.assignedTo || "Unassigned"}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDateTime(row.lastUpdated)}
</TableCell>
<TableCell align="right">
<Tooltip title="View">
<IconButton
size="small"
aria-label="View work order"
onClick={() => navigate(`/workorders/${row.id}`)}
>
<VisibilityOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Edit">
<IconButton
size="small"
aria-label="Edit work order"
onClick={() => navigate(`/workorders/edit/${row.id}`)}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={(_event, nextPage) => setPage(nextPage)}
rowsPerPage={pageSize}
onRowsPerPageChange={(event) => {
setPageSize(Number(event.target.value));
setPage(0);
}}
rowsPerPageOptions={[12, 24, 48]}
/>
{activeDocRow && (
<CompDocDialog
row={activeDocRow}
open={!!activeDocRow}
onOpenChange={(open) => {
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 })}
/>
)}
</Box>
);
}

View file

@ -38,7 +38,7 @@ export function CommentTimeline({
<Stack spacing={2} className={`${maxHeightClass} overflow-y-auto`}>
{sorted.map((item) => (
<Box key={String(item.id)} className="rounded border border-border p-3">
<Stack direction="row" justifyContent="space-between" className="mb-1">
<Stack direction="row" className="mb-1" sx={{ justifyContent: "space-between" }}>
<Typography variant="caption" className="font-semibold">
{item.author}
</Typography>

View file

@ -21,7 +21,7 @@ export function DetailField({ label, value, className, highlight }: DetailFieldP
return (
<Box className={className}>
<Typography variant="caption" color="text.secondary" display="block">
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
{label}
</Typography>
<Typography variant="body2" className={colorClass}>

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate } from "react-router";
import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import {
@ -199,7 +199,7 @@ export function AppTopbar({ onToggleNav }: AppTopbarProps) {
{userInitials}
</Box>
<Box>
<Typography variant="body2" fontWeight={600}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{user?.fullname || "User"}
</Typography>
<Typography variant="caption" color="text.secondary">

View file

@ -24,13 +24,15 @@ export function PageHeader({
return (
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
className={cn(
surface &&
"-mx-2.5 -mt-2.5 shrink-0 border-b border-(--color-border) bg-(--color-bg-surface) px-5 py-4",
className,
)}
sx={{
alignItems: "center",
justifyContent: "space-between",
}}
>
<Stack spacing={0}>
{eyebrow && (
@ -53,7 +55,7 @@ export function PageHeader({
)}
</Stack>
{actions && (
<Stack direction="row" spacing={1} alignItems="center" flexWrap="nowrap">
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "nowrap" }}>
{actions}
</Stack>
)}

View file

@ -9,7 +9,8 @@ import {
TableRow,
} from "@mui/material";
import { TableEmptyRow, TableLoadingRow } from "@/components/ui/table-state-rows";
import type { SortDirection } from "@/domain/work-orders/types/work-order-list";
export type SortDirection = "asc" | "desc" | "";
export type ResourceListColumn<T> = {
id: string;

View file

@ -16,7 +16,7 @@ export function StatusChipFilter({
return (
<div>
<span className="mb-1 block text-[11px] font-semibold text-muted-foreground">{label}</span>
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
<Stack direction="row" spacing={0.75} useFlexGap sx={{ flexWrap: "wrap" }}>
<Chip
label="All"
size="small"

View file

@ -1,6 +1,18 @@
import { API_PATHS } from "@/api/api-paths";
import { apiDelete, apiGet, apiPost } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import {
isWoTableMockEnabled,
shouldUseMockWorkOrderDetail,
} from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
import {
buildMockWorkOrderDetailFromTableRow,
getMockWorkOrderLocations,
getMockWorkOrderTableRow,
getMockWorkOrderVendors,
getMockWorkOrdersList,
MOCK_WO_TABLE_USERS,
} from "@/domain/work-orders/mocks/wo-table-mock-data";
import {
mapDispatchComment,
mapDispatchDetail,
@ -15,7 +27,7 @@ import {
mapWorkOrderDetail,
mapWorkOrderFormToCreatePayload,
mapWorkOrderFormToUpdatePayload,
mapWorkOrderListItems,
mapWorkOrderTableRows,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
import type {
@ -30,9 +42,12 @@ import type {
WorkOrderDetail,
WorkOrderFormValues,
WorkOrderUser,
WorkOrdersListParams,
WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order";
import {
DISPATCH_LOCATION_WO_PAGE_SIZE,
type WorkOrdersListParams,
type WorkOrdersListResult,
} from "@/domain/work-orders/types/work-order-list";
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
const searchParams: Record<string, string | number> = {
@ -56,6 +71,18 @@ function buildSearchParams(params: WorkOrdersListParams): Record<string, string
if (params.locationId) {
searchParams.locationId = params.locationId;
}
if (params.dateFrom) {
searchParams.dateFrom = params.dateFrom;
}
if (params.dateTo) {
searchParams.dateTo = params.dateTo;
}
if (params.type) {
searchParams.type = params.type;
}
if (params.assigneeIds) {
searchParams.assigneeIds = params.assigneeIds;
}
return searchParams;
}
@ -75,8 +102,17 @@ function mapUser(raw: unknown): WorkOrderUser {
};
}
async function mockNetworkDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 250));
}
export const workOrdersApi = {
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
if (isWoTableMockEnabled()) {
await mockNetworkDelay();
return getMockWorkOrdersList(params);
}
const searchParams = buildSearchParams(params);
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
const paginated = handlePaginatedResponse<unknown>(data, {
@ -85,20 +121,45 @@ export const workOrdersApi = {
});
return {
items: mapWorkOrderListItems(paginated.items),
items: mapWorkOrderTableRows(paginated.items),
totalCount: paginated.totalCount,
page: paginated.page,
pageSize: paginated.pageSize,
};
},
getListByLocation: async (
locationId: string | number,
search = "",
): Promise<WorkOrdersListResult> => {
return workOrdersApi.getList({
locationId,
search,
page: 1,
pageSize: DISPATCH_LOCATION_WO_PAGE_SIZE,
sortBy: "number",
sortDir: "asc",
});
},
fetchUsers: async (): Promise<WorkOrderUser[]> => {
if (isWoTableMockEnabled()) {
return MOCK_WO_TABLE_USERS;
}
const data = await apiGet<unknown>(API_PATHS.user.list);
const users = handleApiResponse<unknown[]>(data);
return Array.isArray(users) ? users.map(mapUser) : [];
},
getById: async (id: string | number): Promise<WorkOrderDetail> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
const row = getMockWorkOrderTableRow(id);
if (!row) {
throw new Error("Mock work order not found");
}
return buildMockWorkOrderDetailFromTableRow(row);
}
const data = await apiGet<unknown>(API_PATHS.workOrder.getById, {
searchParams: { id },
});
@ -117,6 +178,22 @@ export const workOrdersApi = {
id: string | number,
values: WorkOrderFormSchemaValues | WorkOrderFormValues,
): Promise<WorkOrderDetail> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
const row = getMockWorkOrderTableRow(id);
if (!row) {
throw new Error("Mock work order not found");
}
return buildMockWorkOrderDetailFromTableRow({
...row,
woNumber: values.workOrderNumber || row.woNumber,
dueDate: values.dueDate || row.dueDate,
scheduledOn: values.scheduledDate || row.scheduledOn,
dispatcherName: values.assignedTo || row.dispatcherName,
pm: values.problem || row.pm,
type: (values.source || row.type) as typeof row.type,
});
}
const payload = mapWorkOrderFormToUpdatePayload(id, values);
const data = await apiPost<unknown>(API_PATHS.workOrder.editWorkorder, payload);
return mapWorkOrderDetail(handleApiResponse(data));
@ -142,50 +219,44 @@ export const workOrdersApi = {
},
changeStatus: async (id: string | number, status: string): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
searchParams: { id, status },
});
},
changeAssignment: async (id: string | number, assignTo: string | number): Promise<void> => {
if (shouldUseMockWorkOrderDetail(id)) {
await mockNetworkDelay();
return;
}
await apiPost<unknown>(API_PATHS.workOrder.changeAssignment, undefined, {
searchParams: { id, assignTo: assignTo || "" },
});
},
fetchLocations: async (): Promise<LocationOption[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderLocations();
}
const data = await apiGet<unknown>(API_PATHS.contact.getLocations);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapLocationOptions(items) : [];
},
fetchEmployeesDropdown: async (): Promise<EmployeeOption[]> => {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(data);
const items = paginated.items.map((item) => {
const record = (typeof item === "object" && item !== null ? item : {}) as Record<
string,
unknown
>;
const firstName = String(record.firstName ?? record.FirstName ?? "");
const lastName = String(record.lastName ?? record.LastName ?? "");
return {
id: record.id ?? record.Id,
name: `${firstName} ${lastName}`.trim(),
};
});
return mapEmployeeOptions(items);
}
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
},
fetchVendorsDropdown: async (trade?: string, siteZip?: string): Promise<VendorDropdownItem[]> => {
if (isWoTableMockEnabled()) {
return getMockWorkOrderVendors();
}
const searchParams: Record<string, string> = {};
if (trade) {
searchParams.trade = trade;

View file

@ -0,0 +1,122 @@
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type {
WOType,
WizardWOStatus,
WorkOrderWizardDraft,
} from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { formatApptRange, todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
const WIZARD_TO_API_STATUS: Record<WizardWOStatus, WorkOrderStatus> = {
Incomplete: "Open",
Pending: "Open",
Scheduled: "Open",
"En Route": "In Progress",
"On Site": "In Progress",
"In Progress": "In Progress",
Completed: "Done",
Rescheduled: "On Hold",
"Pending Quote": "On Hold",
};
const API_TO_WIZARD_STATUS: Partial<Record<WorkOrderStatus, WizardWOStatus>> = {
Open: "Incomplete",
"In Progress": "In Progress",
"On Hold": "Pending",
Done: "Completed",
};
const TYPE_TO_PRIORITY: Record<WOType, string> = {
Emergency: "Sev 1",
Reactive: "Sev 2",
Overdue: "Sev 2",
"Add-On": "Sev 3",
PM: "Sev 3",
};
export function mapWizardStatusToApi(status: WizardWOStatus | ""): WorkOrderStatus {
if (!status) {
return "Open";
}
return WIZARD_TO_API_STATUS[status] ?? "Open";
}
export function mapApiStatusToWizard(status: string): WizardWOStatus | "" {
if (!status) {
return "";
}
if (API_TO_WIZARD_STATUS[status as WorkOrderStatus]) {
return API_TO_WIZARD_STATUS[status as WorkOrderStatus]!;
}
const wizardStatuses = Object.keys(WIZARD_TO_API_STATUS) as WizardWOStatus[];
if (wizardStatuses.includes(status as WizardWOStatus)) {
return status as WizardWOStatus;
}
if (status === "Canceled" || status === "Cancelled") {
return "Canceled" as WizardWOStatus;
}
return "Incomplete";
}
export function mapTypeToPriority(type: WOType | ""): string {
if (!type) {
return "Sev 3";
}
return TYPE_TO_PRIORITY[type];
}
function buildTitle(draft: WorkOrderWizardDraft): string {
if (draft.woNumber) {
return `${draft.type || "Work Order"} — #${draft.woNumber}`;
}
if (draft.locationName) {
return `${draft.type || "Work Order"} — ${draft.locationName}`;
}
return draft.type || "Work Order";
}
function buildDescription(draft: WorkOrderWizardDraft): string {
const parts: string[] = [];
if (draft.pocNotes) {
parts.push(`POC notes: ${draft.pocNotes}`);
}
if (draft.pocName || draft.pocPhone) {
parts.push(`POC: ${[draft.pocName, draft.pocPhone].filter(Boolean).join(" · ")}`);
}
if (draft.apptStart) {
parts.push(`Appt: ${formatApptRange(draft.apptStart, draft.apptEnd)}`);
}
if (draft.vendorNotes) {
parts.push(`Vendor notes: ${draft.vendorNotes}`);
}
if (draft.vendorName) {
parts.push(`Vendor: ${draft.vendorName}`);
}
return parts.join("\n");
}
export function mapWizardDraftToFormValues(draft: WorkOrderWizardDraft): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: draft.woNumber,
title: buildTitle(draft),
locationId: draft.locationId,
priority: mapTypeToPriority(draft.type),
status: mapWizardStatusToApi(draft.status),
createdDate: todayIso(),
dueDate: draft.dueDate,
scheduledDate: draft.scheduleWeekOnly ? "" : draft.scheduledOn,
completedDate: "",
assignedTo: draft.assigneeName,
description: buildDescription(draft),
po: "",
tt: "",
problem: draft.pm,
trade: "",
subTrade: "",
vendorNTE: "",
source: draft.type,
};
}

View file

@ -1,3 +1,4 @@
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import type {
DispatchChecklistItem,
DispatchComment,
@ -9,7 +10,6 @@ import type {
LocationOption,
TaskListTemplate,
UpdateDispatchPayload,
UpliftRequest,
VendorDropdownItem,
WorkOrderAuditEntry,
WorkOrderComment,
@ -18,12 +18,29 @@ import type {
WorkOrderFormValues,
WorkOrderListItem,
} from "@/domain/work-orders/types/work-order";
import type {
CompletionDocStatus,
WorkOrderTableRow,
} from "@/domain/work-orders/types/work-order-table-row";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
import { toDateInputValue } from "@/lib/time-utils";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function toWorkOrderStatus(value: string): WorkOrderStatus {
if (WORK_ORDER_STATUS_OPTIONS.includes(value as WorkOrderStatus)) {
return value as WorkOrderStatus;
}
return "Open";
}
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = record[key];
@ -92,6 +109,114 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
return rawItems.map(mapWorkOrderListItem);
}
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
function readWoType(record: Record<string, unknown>): WOType | "" {
const raw = readString(record, "type", "Type", "source", "Source", "woType", "WoType");
if (WO_TYPES_SET.has(raw)) {
return raw as WOType;
}
return "";
}
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
if (raw === "Yes" || raw === "No" || raw === "NN") {
return raw;
}
return "";
}
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
const item = asRecord(raw);
const scheduledOn = toDateInputValue(
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
);
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
const woNumber = readString(
item,
"woNumber",
"internalWONumber",
"InternalWONumber",
"number",
"workOrderNumber",
"WorkerOrderNumber",
);
const site = readString(item, "site", "Site", "locationCode", "LocationCode");
const location = readString(item, "location", "Location", "locationName", "LocationName");
const dispatcherId = readString(
item,
"dispatcherId",
"DispatcherId",
"assigneeId",
"AssigneeId",
"employeeId",
"EmployeeId",
);
const dispatcherName = readString(
item,
"dispatcherName",
"DispatcherName",
"assignedTo",
"AssignedTo",
"employeeName",
"EmployeeName",
"assignee",
"Assignee",
);
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
const apptTime =
readString(item, "apptTime", "ApptTime") ||
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
const apiStatus = readString(item, "status", "Status");
const locationIdRaw = item.locationId ?? item.LocationId;
const row: WorkOrderTableRow = {
id: readId(item),
woNumber,
site: site || location.slice(0, 8),
type: readWoType(item),
dispatcherId,
dispatcherName,
scheduledOn,
targetWeek: readString(item, "targetWeek", "TargetWeek"),
dayGroup,
dayLabel,
location,
locationId:
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service"),
company: readString(item, "company", "Company", "vendorName", "VendorName"),
tech: readString(item, "tech", "Tech", "technician", "Technician"),
techPhone: readString(item, "techPhone", "TechPhone"),
apptStart,
apptEnd,
apptTime,
status: mapApiStatusToWizard(apiStatus),
docStatus: readDocStatus(item),
pocName: readString(item, "pocName", "PocName", "POCName"),
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
priority: readString(item, "priority", "Priority"),
};
return row;
}
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
return rawItems.map(mapWorkOrderTableRow);
}
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
const item = asRecord(raw);
@ -187,7 +312,7 @@ export function mapWorkOrderDetailToFormValues(detail: WorkOrderDetail): WorkOrd
title: detail.title,
locationId: detail.locationId,
priority: detail.priority,
status: detail.status,
status: toWorkOrderStatus(detail.status),
createdDate: detail.createdDate,
dueDate: detail.dueDate,
scheduledDate: detail.scheduledDate,

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,42 @@
import type { ElementType } from "react";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
export interface CompDocData {
notes: string;
generatedAt?: string;
}
export interface DocFields {
street: string;
city: string;
state: string;
zip: string;
dateCompleted: string;
amazonRep: string;
seaRep: string;
}
export type DocState = "draft" | "generated" | "not-required";
export interface CompletionDocStateConfig {
pillLabel: string;
pillBg: string;
pillText: string;
cardBg: string;
cardBorder: string;
cardIconBg: string;
iconColor: string;
cardTitle: string;
icon: ElementType;
}
export interface CompDocSiteInfo {
street: string;
city: string;
state: string;
zip: string;
label: string;
fullAddress: string;
}
export type CompDocSaveStatus = Exclude<CompletionDocStatus, "">;

View file

@ -0,0 +1,80 @@
export interface WorkOrderDispatchSummary {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;
isCompleted: boolean;
completedBy: string | null;
completedAt: string | null;
}
export interface DispatchSignoff {
id: string | number;
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
signedAt: string;
}
export interface DispatchComment {
id: string | number;
createdDate: string;
commenttext: string;
userName: string;
commenter: string | null;
}
export interface DispatchWorkOrderRef {
id: string | number;
woNumber: string;
}
export interface DispatchDetail {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
woNumber: string;
status: string;
nteAmount: number | null;
scheduledDate: string;
completedDate: string;
description: string;
dispatchedAt: string;
acknowledgedAt: string;
verifiedAt: string;
verifiedBy: string;
checklistItems: DispatchChecklistItem[];
signoffs: DispatchSignoff[];
comments: DispatchComment[];
workOrders: DispatchWorkOrderRef[];
}
export interface DispatchToVendorPayload {
workOrderIds: (string | number)[];
vendorIds: (string | number)[];
nteAmount: number | null;
scheduledDate: string | null;
description: string;
taskListTemplateId: number | null;
customChecklistItems: string[] | null;
}
export interface UpdateDispatchPayload {
id: string | number;
status: string;
nteAmount: number | null;
scheduledDate: string | null;
completedDate: string | null;
description: string;
}

View file

@ -0,0 +1,74 @@
import { ASSIGNEE_FILTER_UNASSIGNED } from "./work-order-list";
import {
ALL_WIZARD_STATUSES,
WO_TYPE_META,
WO_TYPES,
type WizardWOStatus,
type WOType,
} from "./work-order-wizard";
export { ASSIGNEE_FILTER_UNASSIGNED };
export type DateRangeKey =
| "this-week"
| "last-week"
| "this-month"
| "last-3-months"
| "next-week"
| "next-month"
| "custom";
export type CompletionDocFilter = "No" | "Yes" | "NN";
export interface WorkOrderAdvancedFilters {
dateRange: DateRangeKey;
customFrom: string;
customTo: string;
sites: Set<string>;
types: Set<WOType>;
dispatchers: Set<string>;
statuses: Set<WizardWOStatus>;
pmTypes: Set<string>;
vendorTechs: Set<string>;
docs: Set<CompletionDocFilter>;
}
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
dateRange: "this-week",
customFrom: "",
customTo: "",
sites: new Set(),
types: new Set(),
dispatchers: new Set(),
statuses: new Set(),
pmTypes: new Set(),
vendorTechs: new Set(),
docs: new Set(),
};
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
{ key: "this-week", label: "This week" },
{ key: "last-week", label: "Last week" },
{ key: "this-month", label: "This month" },
{ key: "last-3-months", label: "Last 3 months" },
{ key: "next-week", label: "Next week" },
{ key: "next-month", label: "Next month" },
{ key: "custom", label: "Custom" },
];
export const DOC_FILTER_LABELS: Record<CompletionDocFilter, string> = {
No: "Pending",
Yes: "Uploaded",
NN: "N/N",
};
export const COMPLETION_DOC_OPTIONS: CompletionDocFilter[] = ["No", "Yes", "NN"];
export const WO_TYPE_DESCRIPTIONS: Record<WOType, string> = Object.fromEntries(
WO_TYPES.map((type) => [type, WO_TYPE_META[type].desc]),
) as Record<WOType, string>;
export { ALL_WIZARD_STATUSES, WO_TYPES };
export type AdvancedFilterSetKey =
"sites" | "types" | "dispatchers" | "statuses" | "pmTypes" | "vendorTechs" | "docs";

View file

@ -0,0 +1,48 @@
export const WORK_ORDER_STATUS_OPTIONS = [
"Open",
"In Progress",
"On Hold",
"Done",
"Cancelled",
] as const;
export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
export const DISPATCH_STATUS_OPTIONS = [
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUS_OPTIONS)[number];
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITY_OPTIONS)[number];
export type DispatchStatus = (typeof DISPATCH_STATUS_OPTIONS)[number];
export type WorkOrderCommentType = "customer" | "internal";
export interface LocationOption {
id: string | number;
name: string;
}
export interface EmployeeOption {
id: string | number;
name: string;
}
export interface VendorDropdownItem {
id: string | number;
companyName: string;
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
}
export interface TaskListTemplate {
id: string | number;
name: string;
items: { id?: string | number; itemText?: string }[];
}

View file

@ -0,0 +1,97 @@
import type { WorkOrderCommentType, WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderDispatchSummary } from "./dispatch";
export interface WorkOrderComment {
id: string | number;
createdDate: string;
commenttext: string;
commentType: WorkOrderCommentType | string;
userName: string;
commenter: string | null;
recordType: string | null;
}
export interface WorkOrderAuditEntry {
id: string | number;
fieldName: string;
oldValue: string;
newValue: string;
action: string;
createdAt: string;
userName: string;
}
export interface WorkOrderDetail {
id: string | number;
internalWONumber: string;
workOrderNumber: string;
title: string;
location: string;
locationId: string;
locationZip: string;
assignedTo: string;
dueDate: string;
createdDate: string;
createdDateFull: string;
description: string;
priority: WorkOrderPriority | string;
status: WorkOrderStatus | string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
scheduledDate: string;
completedDate: string;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
dispatches: WorkOrderDispatchSummary[];
}
export interface WorkOrderFormValues {
internalWONumber: string;
workOrderNumber: string;
title: string;
locationId: string;
priority: string;
status: WorkOrderStatus;
createdDate: string;
dueDate: string;
scheduledDate: string;
completedDate: string;
assignedTo: string;
description: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
source: string;
}
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: "",
title: "",
locationId: "",
priority: "",
status: "Open",
createdDate: new Date().toISOString().split("T")[0] ?? "",
dueDate: "",
scheduledDate: "",
completedDate: "",
assignedTo: "",
description: "",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
source: "",
};
}

View file

@ -0,0 +1,9 @@
import type { WOType } from "./work-order-wizard";
export type WorkOrderTypeFilter = WOType | "All";
export interface WorkOrderBarFilters {
weekMonday: string;
dispatcherIds: Set<string>;
type: WorkOrderTypeFilter;
}

View file

@ -0,0 +1,61 @@
import type { WorkOrderPriority, WorkOrderStatus } from "./work-order-common";
import type { WorkOrderTableRow } from "./work-order-table-row";
export type SortDirection = "asc" | "desc" | "";
export type WorkOrdersSortableColumn = "number" | "title";
export const ASSIGNEE_FILTER_UNASSIGNED = "__unassigned" as const;
export interface WorkOrderListItem {
id: string | number;
number: string;
title: string;
location: string;
priority: WorkOrderPriority | string;
status: WorkOrderStatus | string;
createdDate: string;
dueDate: string;
assignedTo: string;
lastUpdated: string;
}
export interface WorkOrdersListParams {
search?: string;
page?: number;
pageSize?: number;
sortBy?: WorkOrdersSortableColumn | string;
sortDir?: SortDirection;
status?: string;
assignee?: string;
locationId?: string | number;
/** Phase 2: filter bar week range (Monday ISO). */
dateFrom?: string;
/** Phase 2: filter bar week range (Friday ISO). */
dateTo?: string;
/** Phase 2: filter bar WO type. */
type?: string;
/** Phase 2: filter bar dispatcher ids (format TBD by API contract). */
assigneeIds?: string;
}
export interface WorkOrderUser {
id: string | number;
name: string;
}
export interface WorkOrdersListResult {
items: WorkOrderTableRow[];
totalCount: number;
page: number;
pageSize: number;
}
export interface SelectedWorkOrderRef {
id: string | number;
number: string;
title: string;
}
/** Max page size when loading WOs at a location for dispatch multi-select. */
export const DISPATCH_LOCATION_WO_PAGE_SIZE = 50;

View file

@ -0,0 +1,24 @@
export type FileCategory = "image" | "video" | "pdf" | "doc" | "other";
export type MediaCategory =
"Photo" | "Video" | "Receipt" | "Quote" | "Invoice" | "Report" | "Other";
export interface MediaFile {
id: string;
name: string;
fileType: FileCategory;
url: string;
category: MediaCategory;
}
export const MEDIA_CATEGORIES: MediaCategory[] = [
"Photo",
"Video",
"Receipt",
"Quote",
"Invoice",
"Report",
"Other",
];
export const EMPTY_MEDIA: MediaFile[] = [];

View file

@ -0,0 +1,99 @@
import type { WizardWOStatus, WOType } from "./work-order-wizard";
export type CompletionDocStatus = "Yes" | "No" | "NN" | "";
export const WORK_ORDERS_TABLE_PAGE_SIZE = 500;
export interface WorkOrderTableRow {
id: string | number;
woNumber: string;
site: string;
type: WOType | "";
dispatcherId: string;
dispatcherName: string;
scheduledOn: string;
targetWeek: string;
dayGroup: string;
dayLabel: string;
location: string;
locationId: string | number;
pm: string;
company: string;
tech: string;
techPhone: string;
apptStart: string;
apptEnd: string;
apptTime: string;
status: WizardWOStatus | "";
docStatus: CompletionDocStatus;
pocName: string;
pocPhone: string;
pocNotes: string;
dueDate: string;
rescheduleCount: number;
carriedOver: number;
originalWeek: string;
originalDate: string;
isPastDue: boolean;
scheduleWeekOnly: boolean;
mediaCount: number;
title: string;
priority: string;
}
export interface WorkOrderTableDraft {
site: string;
woNumber: string;
type: WOType | "";
dispatcherId: string;
scheduledOn: string;
schedNotes: string;
dueDate: string;
location: string;
locationId: string | number;
pm: string;
company: string;
tech: string;
techPhone: string;
apptStart: string;
apptEnd: string;
status: WizardWOStatus | "";
pocName: string;
pocPhone: string;
pocNotes: string;
scheduleWeekOnly: boolean;
targetWeek: string;
}
export const EMPTY_TABLE_DRAFT: WorkOrderTableDraft = {
site: "",
woNumber: "",
type: "",
dispatcherId: "",
scheduledOn: "",
schedNotes: "",
dueDate: "",
location: "",
locationId: "",
pm: "",
company: "",
tech: "",
techPhone: "",
apptStart: "",
apptEnd: "",
status: "Incomplete",
pocName: "",
pocPhone: "",
pocNotes: "",
scheduleWeekOnly: false,
targetWeek: "",
};
export interface WorkOrderDayGroup {
key: string;
label: string;
date: string;
items: WorkOrderTableRow[];
}
export const UNSCHEDULED_DAY_KEY = "__unscheduled__" as const;

View file

@ -0,0 +1,149 @@
export const WO_TYPES = ["PM", "Reactive", "Emergency", "Add-On", "Overdue"] as const;
export type WOType = (typeof WO_TYPES)[number];
export const ALL_WIZARD_STATUSES = [
"Incomplete",
"Pending",
"Scheduled",
"En Route",
"On Site",
"In Progress",
"Completed",
"Rescheduled",
"Pending Quote",
] as const;
export type WizardWOStatus = (typeof ALL_WIZARD_STATUSES)[number];
export interface WorkOrderWizardDraft {
locationId: string;
locationName: string;
woNumber: string;
type: WOType | "";
assigneeId: string;
assigneeName: string;
scheduledOn: string;
dueDate: string;
pm: string;
vendorId: string;
vendorName: string;
techPhone: string;
vendorNotes: string;
apptStart: string;
apptEnd: string;
status: WizardWOStatus | "";
pocName: string;
pocPhone: string;
pocNotes: string;
scheduleWeekOnly: boolean;
}
export const EMPTY_WIZARD_DRAFT: WorkOrderWizardDraft = {
locationId: "",
locationName: "",
woNumber: "",
type: "",
assigneeId: "",
assigneeName: "",
scheduledOn: "",
dueDate: "",
pm: "",
vendorId: "",
vendorName: "",
techPhone: "",
vendorNotes: "",
apptStart: "",
apptEnd: "",
status: "Incomplete",
pocName: "",
pocPhone: "",
pocNotes: "",
scheduleWeekOnly: false,
};
export const WIZARD_STEPS: { title: string; nav: string; subtitle: string }[] = [
{
title: "Type & schedule",
nav: "What and when",
subtitle: "Set the work-order type and when it should happen.",
},
{
title: "Location & service",
nav: "Where and what work",
subtitle: "Pick the site and the service to be performed.",
},
{
title: "Vendor & time",
nav: "Who and the window",
subtitle: "Assign a vendor crew and the appointment window.",
},
];
export const WO_TYPE_META: Record<WOType, { desc: string; bg: string; fg: string }> = {
PM: {
desc: "Scheduled preventive maintenance",
bg: "var(--type-pm-bg)",
fg: "var(--type-pm-text)",
},
Reactive: {
desc: "Tenant-reported repair",
bg: "var(--type-reactive-bg)",
fg: "var(--type-reactive-text)",
},
Emergency: {
desc: "Urgent same-day dispatch",
bg: "var(--type-emergency-bg)",
fg: "var(--type-emergency-text)",
},
"Add-On": {
desc: "Extra scope on an existing visit",
bg: "var(--type-addon-bg)",
fg: "var(--type-addon-text)",
},
Overdue: {
desc: "No client response — due date passed",
bg: "var(--type-overdue-bg)",
fg: "var(--type-overdue-text)",
},
};
export const WIZARD_STATUS_DOTS: Record<WizardWOStatus, string> = {
Incomplete: "#9CA3AF",
Pending: "#D97706",
Scheduled: "#3B82F6",
"En Route": "#0EA5E9",
"On Site": "#10B981",
"In Progress": "#2563EB",
Completed: "#16A34A",
Rescheduled: "#F59E0B",
"Pending Quote": "#9333EA",
};
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 canContinueWizardStep1(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.type);
}
export function canCreateWorkOrderFromWizard(draft: WorkOrderWizardDraft): boolean {
return Boolean(draft.locationId);
}
export function isWizardStepValid(step: number, draft: WorkOrderWizardDraft): boolean {
if (step === 1) {
return canContinueWizardStep1(draft);
}
if (step === 2) {
return Boolean(draft.locationId);
}
return true;
}

View file

@ -1,270 +1,61 @@
export interface WorkOrderListItem {
id: string | number;
number: string;
title: string;
location: string;
priority: string;
status: string;
createdDate: string;
dueDate: string;
assignedTo: string;
lastUpdated: string;
}
export type {
DispatchChecklistItem,
DispatchComment,
DispatchDetail,
DispatchSignoff,
DispatchToVendorPayload,
DispatchWorkOrderRef,
UpdateDispatchPayload,
WorkOrderDispatchSummary,
} from "./dispatch";
export interface WorkOrdersListParams {
search?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortDir?: string;
status?: string;
assignee?: string;
locationId?: string | number;
}
export type {
DispatchStatus,
EmployeeOption,
LocationOption,
TaskListTemplate,
VendorDropdownItem,
WorkOrderCommentType,
WorkOrderPriority,
WorkOrderStatus,
} from "./work-order-common";
export interface WorkOrderUser {
id: string | number;
name: string;
}
export {
DISPATCH_STATUS_OPTIONS,
WORK_ORDER_PRIORITY_OPTIONS,
WORK_ORDER_STATUS_OPTIONS,
} from "./work-order-common";
export interface WorkOrdersListResult {
items: WorkOrderListItem[];
totalCount: number;
page: number;
pageSize: number;
}
export type {
WorkOrderAuditEntry,
WorkOrderComment,
WorkOrderDetail,
WorkOrderFormValues,
} from "./work-order-detail";
export interface WorkOrderComment {
id: string | number;
createdDate: string;
commenttext: string;
commentType: string;
userName: string;
commenter: string | null;
recordType: string | null;
}
export { createDefaultWorkOrderFormValues } from "./work-order-detail";
export interface WorkOrderAuditEntry {
id: string | number;
fieldName: string;
oldValue: string;
newValue: string;
action: string;
createdAt: string;
userName: string;
}
export type {
SelectedWorkOrderRef,
SortDirection,
WorkOrderListItem,
WorkOrdersListParams,
WorkOrdersListResult,
WorkOrderUser,
WorkOrdersSortableColumn,
} from "./work-order-list";
export interface WorkOrderDispatchSummary {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
nteAmount: number | null;
status: string;
dispatchedAt: string;
}
export { ASSIGNEE_FILTER_UNASSIGNED, DISPATCH_LOCATION_WO_PAGE_SIZE } from "./work-order-list";
export interface WorkOrderDetail {
id: string | number;
internalWONumber: string;
workOrderNumber: string;
title: string;
location: string;
locationId: string;
locationZip: string;
assignedTo: string;
dueDate: string;
createdDate: string;
createdDateFull: string;
description: string;
priority: string;
status: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
scheduledDate: string;
completedDate: string;
source: string;
comments: WorkOrderComment[];
auditLog: WorkOrderAuditEntry[];
dispatches: WorkOrderDispatchSummary[];
}
export type {
CompletionDocStatus,
WorkOrderDayGroup,
WorkOrderTableDraft,
WorkOrderTableRow,
} from "./work-order-table-row";
export interface WorkOrderFormValues {
internalWONumber: string;
workOrderNumber: string;
title: string;
locationId: string;
priority: string;
status: string;
createdDate: string;
dueDate: string;
scheduledDate: string;
completedDate: string;
assignedTo: string;
description: string;
po: string;
tt: string;
problem: string;
trade: string;
subTrade: string;
vendorNTE: string;
source: string;
}
export interface LocationOption {
id: string | number;
name: string;
}
export interface EmployeeOption {
id: string | number;
name: string;
}
export interface VendorDropdownItem {
id: string | number;
companyName: string;
tradeSpecialties: string;
address: string;
distanceMiles: number | null;
}
export interface TaskListTemplate {
id: string | number;
name: string;
items: { id?: string | number; itemText?: string }[];
}
export interface DispatchChecklistItem {
id: string | number;
itemText: string;
isCompleted: boolean;
completedBy: string | null;
completedAt: string | null;
}
export interface DispatchSignoff {
id: string | number;
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
signedAt: string;
}
export interface DispatchComment {
id: string | number;
createdDate: string;
commenttext: string;
userName: string;
commenter: string | null;
}
export interface DispatchWorkOrderRef {
id: string | number;
woNumber: string;
}
export interface DispatchDetail {
id: string | number;
dispatchNumber: string;
poNumber: string;
vendorName: string;
vendorEmail: string;
woNumber: string;
status: string;
nteAmount: number | null;
scheduledDate: string;
completedDate: string;
description: string;
dispatchedAt: string;
acknowledgedAt: string;
verifiedAt: string;
verifiedBy: string;
checklistItems: DispatchChecklistItem[];
signoffs: DispatchSignoff[];
comments: DispatchComment[];
workOrders: DispatchWorkOrderRef[];
}
export interface DispatchToVendorPayload {
workOrderIds: (string | number)[];
vendorIds: (string | number)[];
nteAmount: number | null;
scheduledDate: string | null;
description: string;
taskListTemplateId: number | null;
customChecklistItems: string[] | null;
}
export interface UpdateDispatchPayload {
id: string | number;
status: string;
nteAmount: number | null;
scheduledDate: string | null;
completedDate: string | null;
description: string;
}
export interface UpliftRequest {
id: string | number;
status: string;
currentNTE: number | null;
requestedNTE: number;
delta: number;
vendorReason: string;
requestedAt: string;
requestedByVendorName: string;
decidedAt: string;
decidedByName: string;
decisionNote: string;
requiredTier: number;
canDecide: boolean;
}
export const WORK_ORDER_STATUS_OPTIONS = [
"Open",
"In Progress",
"On Hold",
"Done",
"Cancelled",
] as const;
export const WORK_ORDER_PRIORITY_OPTIONS = ["Sev 1", "Sev 2", "Sev 3", "Sev 4", "Sev 5"] as const;
export const DISPATCH_STATUS_OPTIONS = [
"Sent",
"Acknowledged",
"In Progress",
"Completed",
"Verified",
"Cancelled",
] as const;
export function createDefaultWorkOrderFormValues(): WorkOrderFormValues {
return {
internalWONumber: "",
workOrderNumber: "",
title: "",
locationId: "",
priority: "",
status: "Open",
createdDate: new Date().toISOString().split("T")[0] ?? "",
dueDate: "",
scheduledDate: "",
completedDate: "",
assignedTo: "",
description: "",
po: "",
tt: "",
problem: "",
trade: "",
subTrade: "",
vendorNTE: "",
source: "",
};
}
export {
EMPTY_TABLE_DRAFT,
UNSCHEDULED_DAY_KEY,
WORK_ORDERS_TABLE_PAGE_SIZE,
} from "./work-order-table-row";

View file

@ -0,0 +1,35 @@
import { API_PATHS } from "@/api/api-paths";
import { apiGet } from "@/api/api";
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
import { mapEmployeeOptions } from "@/domain/work-orders/mappers/work-order-mapper";
import type { EmployeeOption } from "@/domain/work-orders/types/work-order";
async function fetchEmployeesFromList(): Promise<EmployeeOption[]> {
const data = await apiGet<unknown>(API_PATHS.employee.getList, {
searchParams: { page: 1, pageSize: 1000 },
});
const paginated = handlePaginatedResponse<unknown>(data);
const items = paginated.items.map((item) => {
const record = (typeof item === "object" && item !== null ? item : {}) as Record<
string,
unknown
>;
const firstName = String(record.firstName ?? record.FirstName ?? "");
const lastName = String(record.lastName ?? record.LastName ?? "");
return {
id: record.id ?? record.Id,
name: `${firstName} ${lastName}`.trim(),
};
});
return mapEmployeeOptions(items);
}
export async function fetchWorkOrderEmployees(): Promise<EmployeeOption[]> {
try {
const data = await apiGet<unknown>(API_PATHS.employee.dropdown);
const items = handleApiResponse<unknown[]>(data);
return Array.isArray(items) ? mapEmployeeOptions(items) : [];
} catch {
return fetchEmployeesFromList();
}
}

View file

@ -0,0 +1,35 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { shouldUseMockWoWizardCreate } from "@/domain/work-orders/mocks/is-wo-wizard-mock-enabled";
import { buildMockWorkOrderDetail } from "@/domain/work-orders/mocks/wo-wizard-mock-data";
import type { WorkOrderFormValues } from "@/domain/work-orders/types/work-order-detail";
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
import { queryKeys } from "@/infra/query-key/query-key";
async function createWorkOrderFromWizard(values: WorkOrderFormValues): Promise<WorkOrderDetail> {
if (shouldUseMockWoWizardCreate(values.locationId)) {
await new Promise((resolve) => setTimeout(resolve, 400));
return buildMockWorkOrderDetail(values);
}
return workOrdersApi.create(values);
}
export function useCreateWorkOrderFromWizard(
onSuccess?: () => void,
): UseMutationResult<WorkOrderDetail, Error, WorkOrderFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createWorkOrderFromWizard,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
toast.success("Work Order created successfully!");
onSuccess?.();
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create work order");
},
});
}

View file

@ -1,5 +1,6 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import { fetchWorkOrderEmployees } from "@/domain/work-orders/use-cases/fetch-work-order-employees";
import type {
DispatchDetail,
EmployeeOption,
@ -32,7 +33,7 @@ export function useWorkOrderLocations(): UseQueryResult<LocationOption[], Error>
export function useWorkOrderEmployeesDropdown(): UseQueryResult<EmployeeOption[], Error> {
return useQuery({
queryKey: [...queryKeys.employees.all, "dropdown"] as const,
queryFn: () => workOrdersApi.fetchEmployeesDropdown(),
queryFn: () => fetchWorkOrderEmployees(),
staleTime: 5 * 60 * 1000,
});
}

View file

@ -20,32 +20,32 @@ function invalidateWorkOrders(queryClient: ReturnType<typeof useQueryClient>) {
interface AddWorkOrderCommentInput {
text: string;
commentType: string;
commentType: "customer" | "internal";
}
interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
interface AddDispatchCommentInput {
export interface AddDispatchCommentInput {
text: string;
sendEmail: boolean;
}
interface UpdateChecklistItemInput {
export interface UpdateChecklistItemInput {
itemId: string | number;
isCompleted: boolean;
completedBy: string;
}
interface AddDispatchSignoffInput {
export interface AddDispatchSignoffInput {
signoffType: string;
name: string;
signature: string;
signatureMethod: string;
}
interface ChangeWorkOrderAssignmentInput {
userId: string | number;
userName: string;
}
export function useCreateWorkOrder(): UseMutationResult<
WorkOrderDetail,
Error,

View file

@ -0,0 +1,154 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
import {
mapApiStatusToWizard,
mapWizardStatusToApi,
} from "@/domain/work-orders/mappers/wizard-draft-mapper";
import {
mapWorkOrderDetailToFormValues,
mapWorkOrderTableRow,
} from "@/domain/work-orders/mappers/work-order-mapper";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
import { queryKeys } from "@/infra/query-key/query-key";
export type WorkOrderTablePatch = Partial<WorkOrderTableRow>;
async function loadFormAndUpdate(
id: string | number,
patch: (
values: ReturnType<typeof mapWorkOrderDetailToFormValues>,
) => ReturnType<typeof mapWorkOrderDetailToFormValues>,
) {
const detail = await workOrdersApi.getById(id);
const values = mapWorkOrderDetailToFormValues(detail);
const next = patch(values);
await workOrdersApi.update(id, next);
return detail;
}
export function useWorkOrderTableMutations(
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void,
) {
const queryClient = useQueryClient();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
};
const patchMutation = useMutation({
mutationFn: async ({
id,
patch,
useFullUpdate,
}: {
id: string | number;
patch: WorkOrderTablePatch;
useFullUpdate?: boolean;
}) => {
if (patch.status !== undefined && !useFullUpdate) {
const apiStatus = mapWizardStatusToApi(patch.status as WizardWOStatus);
await workOrdersApi.changeStatus(id, apiStatus);
return patch;
}
if (patch.dispatcherId !== undefined && !useFullUpdate) {
await workOrdersApi.changeAssignment(id, patch.dispatcherId || "");
return {
...patch,
dispatcherName: patch.dispatcherName ?? "",
};
}
await loadFormAndUpdate(id, (values) => ({
...values,
workOrderNumber: patch.woNumber ?? values.workOrderNumber,
internalWONumber: patch.woNumber ?? values.internalWONumber,
locationId: patch.locationId != null ? String(patch.locationId) : values.locationId,
dueDate: patch.dueDate ?? values.dueDate,
scheduledDate: patch.scheduledOn ?? values.scheduledDate,
assignedTo: patch.dispatcherName ?? values.assignedTo,
problem: patch.pm ?? values.problem,
source: patch.type ?? values.source,
priority: values.priority,
status:
patch.status !== undefined
? mapWizardStatusToApi(patch.status as WizardWOStatus)
: values.status,
description: [
values.description,
patch.pocNotes ? `POC notes: ${patch.pocNotes}` : "",
patch.pocName || patch.pocPhone
? `POC: ${[patch.pocName, patch.pocPhone].filter(Boolean).join(" · ")}`
: "",
]
.filter(Boolean)
.join("\n"),
}));
return patch;
},
onMutate: async ({ id, patch }) => {
onPatch(id, patch);
},
onSuccess: () => {
invalidate();
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update work order");
invalidate();
},
});
const patchField = (id: string | number, patch: WorkOrderTablePatch, useFullUpdate = false) => {
patchMutation.mutate({ id, patch, useFullUpdate });
};
return {
patchField,
isPending: patchMutation.isPending,
};
}
export function tableRowFromDetail(detail: Awaited<ReturnType<typeof workOrdersApi.getById>>) {
return enrichTableRow(
mapWorkOrderTableRow({
...detail,
status: mapApiStatusToWizard(detail.status),
scheduledOn: detail.scheduledDate,
woNumber: detail.internalWONumber || detail.workOrderNumber,
dispatcherName: detail.assignedTo,
pm: detail.problem,
type: detail.source,
}),
);
}
export function maybeAutoSchedulePatch(row: WorkOrderTableRow): WorkOrderTablePatch {
const hasDate = !!row.scheduledOn;
const hasDispatcher = !!row.dispatcherId;
if (hasDate && hasDispatcher && row.status === "Incomplete") {
return { status: "Scheduled" };
}
return {};
}
export function applySchedulePatch(
scheduledOn: string,
scheduleWeekOnly: boolean,
): WorkOrderTablePatch {
if (scheduleWeekOnly) {
return {
scheduledOn: "",
targetWeek: scheduledOn,
scheduleWeekOnly: true,
...deriveDayFields(""),
};
}
return {
scheduledOn,
scheduleWeekOnly: false,
targetWeek: "",
...deriveDayFields(scheduledOn),
};
}

View file

@ -11,7 +11,7 @@ export function useWorkOrdersList(
params: WorkOrdersListParams,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.list(params as Record<string, unknown>),
queryKey: queryKeys.workOrders.list(params),
queryFn: () => workOrdersApi.getList(params),
placeholderData: keepPreviousData,
});
@ -24,3 +24,16 @@ export function useWorkOrderUsers(): UseQueryResult<WorkOrderUser[], Error> {
staleTime: 5 * 60 * 1000,
});
}
export function useWorkOrdersByLocation(
locationId: string | number | undefined,
search = "",
enabled = true,
): UseQueryResult<WorkOrdersListResult, Error> {
return useQuery({
queryKey: queryKeys.workOrders.byLocation(locationId ?? "", search),
queryFn: () => workOrdersApi.getListByLocation(locationId!, search),
enabled: enabled && locationId !== undefined && locationId !== "",
staleTime: 30 * 1000,
});
}

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

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { mapWizardDraftToFormValues } from "@/domain/work-orders/mappers/wizard-draft-mapper";
import {
EMPTY_WIZARD_DRAFT,
canContinueWizardStep1,
canCreateWorkOrderFromWizard,
isWizardStepValid,
} from "@/domain/work-orders/types/work-order-wizard";
describe("mapWizardDraftToFormValues", () => {
it("maps minimal draft with type and site to valid form values", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
locationId: "42",
locationName: "DFW8 · Dallas, TX",
woNumber: "24818",
assigneeName: "Sarah Chen",
status: "Incomplete" as const,
};
const values = mapWizardDraftToFormValues(draft);
expect(values.title).toBe("PM — #24818");
expect(values.workOrderNumber).toBe("24818");
expect(values.locationId).toBe("42");
expect(values.assignedTo).toBe("Sarah Chen");
expect(values.status).toBe("Open");
expect(values.priority).toBe("Sev 3");
expect(values.source).toBe("PM");
expect(values.createdDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("maps emergency type to Sev 1 priority", () => {
const values = mapWizardDraftToFormValues({
...EMPTY_WIZARD_DRAFT,
type: "Emergency",
locationId: "1",
locationName: "Site A",
});
expect(values.priority).toBe("Sev 1");
expect(values.title).toBe("Emergency — Site A");
});
it("includes appointment and notes in description", () => {
const values = mapWizardDraftToFormValues({
...EMPTY_WIZARD_DRAFT,
type: "Reactive",
locationId: "1",
locationName: "Site A",
apptStart: "09:00",
apptEnd: "11:00",
pocNotes: "Call ahead",
vendorName: "Roto-Rooter",
vendorNotes: "Prefers morning",
});
expect(values.description).toContain("Appt:");
expect(values.description).toContain("POC notes: Call ahead");
expect(values.description).toContain("Vendor: Roto-Rooter");
});
});
describe("wizard step validation", () => {
it("blocks step 1 continue without type", () => {
expect(canContinueWizardStep1(EMPTY_WIZARD_DRAFT)).toBe(false);
expect(isWizardStepValid(1, EMPTY_WIZARD_DRAFT)).toBe(false);
});
it("allows step 1 continue when type is selected", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canContinueWizardStep1(draft)).toBe(true);
expect(isWizardStepValid(1, draft)).toBe(true);
});
it("blocks create without location", () => {
const draft = { ...EMPTY_WIZARD_DRAFT, type: "PM" as const };
expect(canCreateWorkOrderFromWizard(draft)).toBe(false);
expect(isWizardStepValid(2, draft)).toBe(false);
});
it("allows create when location is selected", () => {
const draft = {
...EMPTY_WIZARD_DRAFT,
type: "PM" as const,
locationId: "99",
};
expect(canCreateWorkOrderFromWizard(draft)).toBe(true);
expect(isWizardStepValid(2, draft)).toBe(true);
});
});

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
ASSIGNEE_FILTER_UNASSIGNED,
EMPTY_ADVANCED_FILTERS,
type WorkOrderAdvancedFilters,
} from "@/domain/work-orders/types/work-order-advanced-filters";
import {
buildAdvancedFilterChips,
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
function withSites(...sites: string[]): WorkOrderAdvancedFilters {
return {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
sites: new Set(sites),
};
}
describe("countAdvancedFilters", () => {
it("returns 0 for default empty filters", () => {
expect(countAdvancedFilters(EMPTY_ADVANCED_FILTERS)).toBe(0);
});
it("counts non-default date range as one active group", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
dateRange: "last-week" as const,
};
expect(countAdvancedFilters(filters)).toBe(1);
});
it("counts each active filter group once", () => {
const filters = {
...withSites("1", "2"),
types: new Set(["PM" as const]),
statuses: new Set(["Pending" as const]),
};
expect(countAdvancedFilters(filters)).toBe(3);
});
});
describe("buildAdvancedFilterChips", () => {
it("shows site labels when two or fewer sites are selected", () => {
const chips = buildAdvancedFilterChips(withSites("1"), {
siteNameOf: (id) => (id === "1" ? "DFW8" : id),
});
expect(chips).toHaveLength(1);
expect(chips[0]).toMatchObject({
key: "sites",
group: "Site",
value: "DFW8",
});
});
it("shows a count badge when more than two sites are selected", () => {
const chips = buildAdvancedFilterChips(withSites("1", "2", "3"));
expect(chips).toHaveLength(1);
expect(chips[0]).toMatchObject({
key: "sites",
group: "Site",
count: 3,
});
});
it("creates a separate unassigned chip for dispatchers", () => {
const filters = {
...cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS),
dispatchers: new Set([ASSIGNEE_FILTER_UNASSIGNED, "42"]),
};
const chips = buildAdvancedFilterChips(filters, {
dispatcherNameOf: (id) => (id === "42" ? "Sarah Chen" : id),
});
expect(
chips.some((chip) => chip.key === "disp-unassigned" && chip.value === "Unassigned"),
).toBe(true);
expect(chips.some((chip) => chip.key === "disp" && chip.value === "Sarah Chen")).toBe(true);
});
});
describe("clearAdvancedFilterGroup", () => {
it("removes only the targeted filter group", () => {
const filters = {
...withSites("1"),
types: new Set(["PM" as const]),
};
const next = clearAdvancedFilterGroup(filters, "sites");
expect(next?.sites.size).toBe(0);
expect(next?.types.has("PM")).toBe(true);
});
it("returns null when clearing the last active group", () => {
const next = clearAdvancedFilterGroup(withSites("1"), "sites");
expect(next).toBeNull();
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
buildWeekWindow,
compactWeekLabel,
currentWeekMonday,
isFutureWeek,
nextWeekMonday,
weekRange,
} from "@/domain/work-orders/utils/list-week-utils";
import { addDaysIso, mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
describe("list-week-utils", () => {
it("currentWeekMonday returns a Monday ISO date", () => {
const monday = currentWeekMonday();
expect(monday).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(mondayOf(monday)).toBe(monday);
});
it("compactWeekLabel formats Mon–Fri range", () => {
expect(compactWeekLabel("2026-06-01")).toBe("6/1–6/5");
expect(compactWeekLabel("2026-05-25")).toBe("5/25–5/29");
});
it("weekRange returns Monday through Friday", () => {
expect(weekRange("2026-06-01")).toEqual(["2026-06-01", "2026-06-05"]);
});
it("buildWeekWindow generates ordered weeks around anchor", () => {
const weeks = buildWeekWindow("2026-06-01", 2, 2);
expect(weeks.length).toBeGreaterThanOrEqual(3);
expect(weeks[0].mondayIso).toBe("2026-05-18");
expect(weeks.some((week) => week.mondayIso === "2026-06-01")).toBe(true);
expect(weeks.every((week) => week.label.length > 0)).toBe(true);
});
it("buildWeekWindow always includes the next week even when it is in the future", () => {
const upcoming = nextWeekMonday();
const weeks = buildWeekWindow(currentWeekMonday(), 2, 0);
expect(weeks.some((week) => week.mondayIso === upcoming)).toBe(true);
if (isFutureWeek(upcoming)) {
expect(isFutureWeek(upcoming)).toBe(true);
}
});
it("buildWeekWindow always includes the current live week", () => {
const liveMonday = currentWeekMonday();
const weeks = buildWeekWindow("2000-01-03", 2, 0);
expect(weeks.some((week) => week.mondayIso === liveMonday && week.live)).toBe(true);
});
it("isFutureWeek identifies weeks after the current week", () => {
const live = currentWeekMonday();
expect(isFutureWeek(live)).toBe(false);
expect(isFutureWeek("2099-01-05")).toBe(true);
});
});

View file

@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters";
import {
buildDayGroups,
matchesAdvancedFilters,
splitScheduledUnscheduled,
} from "@/domain/work-orders/utils/table-filter-utils";
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
import { mapWorkOrderTableRow } from "@/domain/work-orders/mappers/work-order-mapper";
function makeRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: "1",
woNumber: "12345678901",
site: "SITE01",
type: "PM",
dispatcherId: "u1",
dispatcherName: "Alice",
scheduledOn: "2026-06-23",
targetWeek: "",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
location: "Main St",
locationId: "10",
pm: "HVAC",
company: "Acme",
tech: "Bob",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
dueDate: "2026-06-30",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "Test WO",
priority: "Sev 3",
...overrides,
};
}
describe("deriveDayFields", () => {
it("derives MM/DD group and weekday label", () => {
expect(deriveDayFields("2026-06-23")).toEqual({
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
});
});
});
describe("splitScheduledUnscheduled", () => {
it("splits rows without scheduledOn into unscheduled", () => {
const scheduled = makeRow({ id: "a", scheduledOn: "2026-06-23" });
const unsched = makeRow({ id: "b", scheduledOn: "" });
const result = splitScheduledUnscheduled([scheduled, unsched]);
expect(result.scheduled).toHaveLength(1);
expect(result.unscheduled).toHaveLength(1);
expect(result.unscheduled[0].id).toBe("b");
});
});
describe("buildDayGroups", () => {
it("groups by dayGroup and sorts chronologically", () => {
const later = makeRow({
id: "2",
scheduledOn: "2026-06-24",
dayGroup: "06/24",
dayLabel: "WED · 06/24",
});
const earlier = makeRow({
id: "1",
scheduledOn: "2026-06-23",
dayGroup: "06/23",
dayLabel: "TUE · 06/23",
});
const groups = buildDayGroups([later, earlier]);
expect(groups).toHaveLength(2);
expect(groups[0].key).toBe("06/23");
expect(groups[1].key).toBe("06/24");
});
});
describe("matchesAdvancedFilters", () => {
it("filters by site and type", () => {
const row = makeRow({ site: "SITE01", type: "PM" });
const filters = {
...EMPTY_ADVANCED_FILTERS,
sites: new Set(["SITE01"]),
types: new Set(["PM" as const]),
};
expect(matchesAdvancedFilters(row, filters)).toBe(true);
const noMatch = {
...filters,
types: new Set(["Emergency" as const]),
};
expect(matchesAdvancedFilters(row, noMatch)).toBe(false);
});
});
describe("mapWorkOrderTableRow", () => {
it("maps API list fields to table row", () => {
const row = mapWorkOrderTableRow({
id: 42,
internalWONumber: "12345678901",
title: "Leak repair",
location: "Building A",
status: "Open",
scheduledDate: "2026-06-23",
dueDate: "2026-06-30",
assignedTo: "Alice",
problem: "Plumbing",
source: "Reactive",
});
expect(row.id).toBe(42);
expect(row.woNumber).toBe("12345678901");
expect(row.scheduledOn).toBe("2026-06-23");
expect(row.dayGroup).toBe("06/23");
expect(row.type).toBe("Reactive");
expect(row.status).toBe("Incomplete");
});
});