mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-05 10:42:04 +00:00
* 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
339 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|