feat(work-orders): add weekly board page shell and list chrome

This commit is contained in:
Arthur Bassi 2026-07-17 12:03:50 -03:00
parent e446bbae0e
commit af0e4aa1a6
12 changed files with 1194 additions and 787 deletions

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