shoc-frontend-new/src/app/(protected)/workorders/index.tsx
Alexandre Brandizzi c68d47af7b
Some checks failed
CI / ci (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
chore: upgrade frontend dependencies (#24)
* chore: upgrade frontend application dependencies

* chore: upgrade frontend infrastructure dependencies

* fix: align lockfiles with CI npm

* fix: preserve ky error semantics

* fix: sanitize timeout error message
2026-07-14 21:35:59 -03:00

339 lines
10 KiB
TypeScript

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 {
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();
}
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 debouncedSearch = useDebounce(search, 300);
const listParams = useMemo(
() => ({
search: debouncedSearch,
page: page + 1,
pageSize,
sortBy,
sortDir,
status: statusFilter,
assignee: assigneeFilter,
}),
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
);
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
const { data: users = [] } = useWorkOrderUsers();
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleSort = (column: string) => {
if (sortBy === column) {
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
if (sortDir === "desc") {
setSortBy("");
}
return;
}
setSortBy(column);
setSortDir("asc");
};
const sortIndicator = (column: string) => {
if (sortBy !== column) {
return "";
}
return sortDir === "desc" ? " ↓" : " ↑";
};
const handleStatusFilter = (status: string) => {
setStatusFilter((current) => (current === status ? "" : status));
setPage(0);
};
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]"
/>
<Button variant="contained" onClick={() => navigate("/workorders/new")}>
+ New Work Order
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
useFlexGap
sx={{
flexWrap: "wrap",
alignItems: "center",
}}
>
<Chip
label="All"
clickable
color={statusFilter === "" ? "primary" : "default"}
variant={statusFilter === "" ? "filled" : "outlined"}
onClick={() => {
setStatusFilter("");
setPage(0);
}}
/>
{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]}
/>
</Box>
);
}