mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 04:12:09 +00:00
feat(work-orders): add weekly board page shell and list chrome
This commit is contained in:
parent
e446bbae0e
commit
af0e4aa1a6
12 changed files with 1194 additions and 787 deletions
|
|
@ -1,36 +1,20 @@
|
||||||
import { useMemo, useState } from "react";
|
import { Link as RouterLink, useNavigate } from "react-router";
|
||||||
import { Link as RouterLink, useNavigate, useParams } from "react-router";
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
Button,
|
Button,
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Link,
|
Link,
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
Stack,
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { DispatchCreateModal } from "./_components/dispatch-create-modal";
|
||||||
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
|
||||||
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
import { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections";
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu";
|
||||||
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
import {
|
import {
|
||||||
useAddWorkOrderComment,
|
useAddWorkOrderComment,
|
||||||
|
|
@ -38,164 +22,20 @@ import {
|
||||||
useChangeWorkOrderStatus,
|
useChangeWorkOrderStatus,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
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() {
|
export default function WorkOrderViewPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const workOrderId = id ?? "";
|
const workOrderId = id ?? "";
|
||||||
|
|
||||||
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
|
const { data: wo, isLoading, error } = useWorkOrderDetail(workOrderId);
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
const { data: users = [] } = useWorkOrderUsers();
|
||||||
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
const statusMutation = useChangeWorkOrderStatus(workOrderId);
|
||||||
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
|
||||||
const commentMutation = useAddWorkOrderComment(workOrderId);
|
const commentMutation = useAddWorkOrderComment(workOrderId);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
|
||||||
const [commentText, setCommentText] = useState("");
|
|
||||||
const [dispatchOpen, setDispatchOpen] = useState(false);
|
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||||
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -221,14 +61,9 @@ export default function WorkOrderViewPage() {
|
||||||
<Link component={RouterLink} to="/workorders" underline="hover">
|
<Link component={RouterLink} to="/workorders" underline="hover">
|
||||||
Work Orders
|
Work Orders
|
||||||
</Link>
|
</Link>
|
||||||
<Typography
|
<Typography color="text.primary">{wo.internalWONumber}</Typography>
|
||||||
sx={{
|
|
||||||
color: "text.primary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wo.internalWONumber}
|
|
||||||
</Typography>
|
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: "column", md: "row" }}
|
direction={{ xs: "column", md: "row" }}
|
||||||
spacing={2}
|
spacing={2}
|
||||||
|
|
@ -241,41 +76,13 @@ export default function WorkOrderViewPage() {
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
{wo.title}
|
{wo.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<WorkOrderStatusMenu
|
||||||
label={wo.status}
|
status={wo.status}
|
||||||
color={STATUS_COLORS[wo.status] ?? "default"}
|
onStatusChange={(status) => statusMutation.mutate(status)}
|
||||||
onClick={(event) => setStatusAnchor(event.currentTarget)}
|
|
||||||
className="w-fit cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
||||||
|
|
||||||
<Stack
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
||||||
direction="row"
|
|
||||||
spacing={1}
|
|
||||||
sx={{
|
|
||||||
flexWrap: "wrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
|
||||||
Dispatch
|
Dispatch
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -287,283 +94,27 @@ export default function WorkOrderViewPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</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} />}
|
<WorkOrderDetailsSection
|
||||||
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
wo={wo}
|
||||||
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
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 && (
|
{dispatchOpen && (
|
||||||
<DispatchCreateModal
|
<DispatchCreateModal workOrder={wo} onClose={() => setDispatchOpen(false)} />
|
||||||
workOrder={wo}
|
|
||||||
onClose={() => setDispatchOpen(false)}
|
|
||||||
onDispatched={() => void refetch()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dispatchDetailId && (
|
{dispatchDetailId && (
|
||||||
<DispatchDetailModal
|
<DispatchDetailModal
|
||||||
dispatchId={dispatchDetailId}
|
dispatchId={dispatchDetailId}
|
||||||
workOrderId={workOrderId}
|
workOrderId={workOrderId}
|
||||||
onClose={() => setDispatchDetailId(null)}
|
onClose={() => setDispatchDetailId(null)}
|
||||||
onUpdated={() => void refetch()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,339 +1,191 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { Box } from "@mui/material";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
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 {
|
import {
|
||||||
Alert,
|
useWorkOrderLocations,
|
||||||
Box,
|
useWorkOrderVendors,
|
||||||
Button,
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
Chip,
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
CircularProgress,
|
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
||||||
FormControl,
|
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
||||||
IconButton,
|
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
||||||
InputLabel,
|
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
MenuItem,
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
Paper,
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
Select,
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
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() {
|
export default function WorkOrdersListPage() {
|
||||||
const navigate = useNavigate();
|
const { user } = useAuthContext();
|
||||||
const [search, setSearch] = useState("");
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
const [sortBy, setSortBy] = useState("");
|
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
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(
|
const pmTypes = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
search: debouncedSearch,
|
isWoTableMockEnabled()
|
||||||
page: page + 1,
|
? getMockPmTypes()
|
||||||
pageSize,
|
: problemOptions.map((option) => option.value).filter(Boolean),
|
||||||
sortBy,
|
[problemOptions],
|
||||||
sortDir,
|
|
||||||
status: statusFilter,
|
|
||||||
assignee: assigneeFilter,
|
|
||||||
}),
|
|
||||||
[debouncedSearch, page, pageSize, sortBy, sortDir, statusFilter, assigneeFilter],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, isFetching, error } = useWorkOrdersList(listParams);
|
const vendorTechs = useMemo(
|
||||||
const { data: users = [] } = useWorkOrderUsers();
|
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
||||||
|
[vendors],
|
||||||
|
);
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const totalCount = tableData.totalCount;
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const scopeTotalCount = tableData.scopeTotalCount;
|
||||||
|
|
||||||
const handleSort = (column: string) => {
|
const activeDocRow = useMemo(() => {
|
||||||
if (sortBy === column) {
|
if (!docRow) return null;
|
||||||
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
|
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
|
||||||
if (sortDir === "desc") {
|
}, [docRow, tableData.rows]);
|
||||||
setSortBy("");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSortBy(column);
|
const activeMediaRow = useMemo(() => {
|
||||||
setSortDir("asc");
|
if (!mediaRow) return null;
|
||||||
};
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
|
}, [mediaRow, tableData.rows]);
|
||||||
|
|
||||||
const sortIndicator = (column: string) => {
|
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||||
if (sortBy !== column) {
|
setSlideOverRow(row);
|
||||||
return "";
|
setSlideOverEdit(edit);
|
||||||
}
|
|
||||||
return sortDir === "desc" ? " ↓" : " ↑";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStatusFilter = (status: string) => {
|
|
||||||
setStatusFilter((current) => (current === status ? "" : status));
|
|
||||||
setPage(0);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
<Stack
|
<PageHeader
|
||||||
direction={{ xs: "column", md: "row" }}
|
surface
|
||||||
spacing={2}
|
eyebrow="OPERATIONS"
|
||||||
sx={{
|
title="Work Orders"
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
actions={
|
||||||
justifyContent: "space-between",
|
<WorkOrdersListHeader
|
||||||
}}
|
search={filters.search}
|
||||||
>
|
onSearchChange={filters.handleSearchChange}
|
||||||
<Stack
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
direction="row"
|
onNewWorkOrder={() => setWizardOpen(true)}
|
||||||
spacing={2}
|
activeFilterCount={filters.activeFilterCount}
|
||||||
sx={{
|
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||||
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>
|
<WorkOrdersFilterBar
|
||||||
</Stack>
|
weekMonday={filters.weekMonday}
|
||||||
<Stack
|
onWeekChange={filters.setWeekMonday}
|
||||||
direction="row"
|
dispatcherIds={filters.dispatcherIds}
|
||||||
spacing={1}
|
onDispatcherChange={filters.setDispatcherIds}
|
||||||
useFlexGap
|
typeFilter={filters.typeFilter}
|
||||||
sx={{
|
onTypeChange={filters.setTypeFilter}
|
||||||
flexWrap: "wrap",
|
users={users}
|
||||||
alignItems: "center",
|
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"
|
{activeMediaRow && (
|
||||||
clickable
|
<MediaUploadModal
|
||||||
color={statusFilter === "" ? "primary" : "default"}
|
row={activeMediaRow}
|
||||||
variant={statusFilter === "" ? "filled" : "outlined"}
|
open={!!activeMediaRow}
|
||||||
onClick={() => {
|
onOpenChange={(open) => {
|
||||||
setStatusFilter("");
|
if (!open) setMediaRow(null);
|
||||||
setPage(0);
|
}}
|
||||||
|
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">
|
{activeDocRow && (
|
||||||
<TableHead>
|
<CompDocDialog
|
||||||
<TableRow>
|
row={activeDocRow}
|
||||||
<TableCell
|
open={!!activeDocRow}
|
||||||
className="cursor-pointer whitespace-nowrap font-semibold"
|
onOpenChange={(open) => {
|
||||||
onClick={() => handleSort("number")}
|
if (!open) setDocRow(null);
|
||||||
>
|
}}
|
||||||
WO #{sortIndicator("number")}
|
data={completionDocs[String(activeDocRow.id)]}
|
||||||
</TableCell>
|
onSave={(data, status) => {
|
||||||
<TableCell
|
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
||||||
className="cursor-pointer font-semibold"
|
patchField(activeDocRow.id, { docStatus: status });
|
||||||
onClick={() => handleSort("title")}
|
}}
|
||||||
>
|
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
|
||||||
Title{sortIndicator("title")}
|
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
|
||||||
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue