mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 07:42:04 +00:00
fix(work-orders): restore detail modules and dispatch modal props
Reconcile [id]/index with domain-tests base so this shell slice typechecks; keep onDispatched/onUpdated wiring; use woNumber on table rows.
This commit is contained in:
parent
e78b1ad6ba
commit
11f2c8b72c
2 changed files with 792 additions and 194 deletions
|
|
@ -1,20 +1,36 @@
|
||||||
import { Link as RouterLink, useNavigate } from "react-router";
|
import { useMemo, useState } from "react";
|
||||||
|
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 { useState } from "react";
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
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 { WorkOrderDetailsSection } from "./_components/detail/work-order-detail-sections";
|
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||||
import { WorkOrderStatusMenu } from "./_components/detail/work-order-status-menu";
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
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,
|
||||||
|
|
@ -22,20 +38,167 @@ 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 } = useWorkOrderDetail(workOrderId);
|
const { data: wo, isLoading, error, refetch } = 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;
|
||||||
|
}
|
||||||
|
if (activeTab !== "customer" && activeTab !== "internal") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commentMutation.mutate(
|
||||||
|
{ text: commentText.trim(), commentType: activeTab },
|
||||||
|
{ onSuccess: () => setCommentText("") },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,9 +224,14 @@ 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 color="text.primary">{wo.internalWONumber}</Typography>
|
<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}
|
||||||
|
|
@ -76,13 +244,41 @@ export default function WorkOrderViewPage() {
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
{wo.title}
|
{wo.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<WorkOrderStatusMenu
|
<Chip
|
||||||
status={wo.status}
|
label={wo.status}
|
||||||
onStatusChange={(status) => statusMutation.mutate(status)}
|
color={STATUS_COLORS[wo.status] ?? "default"}
|
||||||
|
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 direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
|
<Stack
|
||||||
|
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>
|
||||||
|
|
@ -94,27 +290,283 @@ 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>
|
||||||
|
|
||||||
<WorkOrderDetailsSection
|
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
|
||||||
wo={wo}
|
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
|
||||||
users={users}
|
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||||
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 workOrder={wo} onClose={() => setDispatchOpen(false)} />
|
<DispatchCreateModal
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,191 +1,337 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Box } from "@mui/material";
|
import { useNavigate } from "react-router";
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||||
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 {
|
||||||
useWorkOrderLocations,
|
Alert,
|
||||||
useWorkOrderVendors,
|
Box,
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
Button,
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
Chip,
|
||||||
import { isWoTableMockEnabled } from "@/domain/work-orders/mocks/is-wo-table-mock-enabled";
|
CircularProgress,
|
||||||
import { getMockPmTypes } from "@/domain/work-orders/mocks/wo-table-mock-data";
|
FormControl,
|
||||||
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
IconButton,
|
||||||
import { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
InputLabel,
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
MenuItem,
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
Paper,
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import type { WorkOrderTableRow } 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 { user } = useAuthContext();
|
const navigate = useNavigate();
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [search, setSearch] = useState("");
|
||||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const [page, setPage] = useState(0);
|
||||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
const [sortBy, setSortBy] = useState("");
|
||||||
const [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
const [sortDir, setSortDir] = useState<"asc" | "desc" | "">("");
|
||||||
const filters = useWorkOrdersListFilters(user?.id);
|
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
|
||||||
const { patchField } = useWorkOrderTableMutations(tableData.patchRow);
|
|
||||||
|
|
||||||
|
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 { data: users = [] } = useWorkOrderUsers();
|
||||||
const { data: locations = [] } = useWorkOrderLocations();
|
|
||||||
const { data: vendors = [] } = useWorkOrderVendors();
|
|
||||||
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
|
||||||
|
|
||||||
const pmTypes = useMemo(
|
const rows = data?.items ?? [];
|
||||||
() =>
|
const totalCount = data?.totalCount ?? 0;
|
||||||
isWoTableMockEnabled()
|
|
||||||
? getMockPmTypes()
|
|
||||||
: problemOptions.map((option) => option.value).filter(Boolean),
|
|
||||||
[problemOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const vendorTechs = useMemo(
|
const handleSort = (column: string) => {
|
||||||
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
if (sortBy === column) {
|
||||||
[vendors],
|
setSortDir((current) => (current === "asc" ? "desc" : current === "desc" ? "" : "asc"));
|
||||||
);
|
if (sortDir === "desc") {
|
||||||
|
setSortBy("");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const totalCount = tableData.totalCount;
|
setSortBy(column);
|
||||||
const scopeTotalCount = tableData.scopeTotalCount;
|
setSortDir("asc");
|
||||||
|
};
|
||||||
|
|
||||||
const activeDocRow = useMemo(() => {
|
const sortIndicator = (column: string) => {
|
||||||
if (!docRow) return null;
|
if (sortBy !== column) {
|
||||||
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
|
return "";
|
||||||
}, [docRow, tableData.rows]);
|
}
|
||||||
|
return sortDir === "desc" ? " ↓" : " ↑";
|
||||||
|
};
|
||||||
|
|
||||||
const activeMediaRow = useMemo(() => {
|
const handleStatusFilter = (status: string) => {
|
||||||
if (!mediaRow) return null;
|
setStatusFilter((current) => (current === status ? "" : status));
|
||||||
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
setPage(0);
|
||||||
}, [mediaRow, tableData.rows]);
|
|
||||||
|
|
||||||
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
|
||||||
setSlideOverRow(row);
|
|
||||||
setSlideOverEdit(edit);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<PageHeader
|
<Stack
|
||||||
surface
|
direction={{ xs: "column", md: "row" }}
|
||||||
eyebrow="OPERATIONS"
|
spacing={2}
|
||||||
title="Work Orders"
|
sx={{
|
||||||
actions={
|
alignItems: { xs: "stretch", md: "center" },
|
||||||
<WorkOrdersListHeader
|
justifyContent: "space-between",
|
||||||
search={filters.search}
|
|
||||||
onSearchChange={filters.handleSearchChange}
|
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
|
||||||
onNewWorkOrder={() => setWizardOpen(true)}
|
|
||||||
activeFilterCount={filters.activeFilterCount}
|
|
||||||
isFetching={tableData.isFetching && !tableData.isLoading}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WorkOrdersFilterBar
|
|
||||||
weekMonday={filters.weekMonday}
|
|
||||||
onWeekChange={filters.setWeekMonday}
|
|
||||||
dispatcherIds={filters.dispatcherIds}
|
|
||||||
onDispatcherChange={filters.setDispatcherIds}
|
|
||||||
typeFilter={filters.typeFilter}
|
|
||||||
onTypeChange={filters.setTypeFilter}
|
|
||||||
users={users}
|
|
||||||
currentUserId={user?.id}
|
|
||||||
filteredCount={totalCount}
|
|
||||||
scopeTotalCount={scopeTotalCount}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{filters.advApplied && (
|
|
||||||
<WorkOrdersAdvancedFiltersBanner
|
|
||||||
applied={filters.advApplied}
|
|
||||||
totalCount={totalCount}
|
|
||||||
sites={locations}
|
|
||||||
users={users}
|
|
||||||
onClearChip={filters.clearAdvancedFilterChip}
|
|
||||||
onClearAll={filters.clearAdvancedFilters}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<WoTable
|
|
||||||
tableData={tableData}
|
|
||||||
users={users}
|
|
||||||
sites={locations}
|
|
||||||
vendors={vendors}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
onOpenSlideOver={handleOpenSlideOver}
|
|
||||||
onOpenCompDoc={setDocRow}
|
|
||||||
onOpenMedia={setMediaRow}
|
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
|
||||||
|
|
||||||
<WorkOrdersAdvancedSearchSheet
|
|
||||||
open={filters.advOpen}
|
|
||||||
onOpenChange={filters.setAdvOpen}
|
|
||||||
initial={filters.advInitial}
|
|
||||||
onApply={filters.applyAdvancedFilters}
|
|
||||||
onClearAll={filters.clearAdvancedFilters}
|
|
||||||
sites={locations}
|
|
||||||
users={users}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
vendorTechs={vendorTechs}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WorkOrderSlideOver
|
|
||||||
row={slideOverRow}
|
|
||||||
editMode={slideOverEdit}
|
|
||||||
users={users}
|
|
||||||
onClose={() => {
|
|
||||||
setSlideOverRow(null);
|
|
||||||
setSlideOverEdit(false);
|
|
||||||
tableData.setSelectedId(null);
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<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: WorkOrderTableRow) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/workorders/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.woNumber || 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.scheduledOn)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.tech || "Unassigned"}</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{formatDateTime(row.dueDate)}</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]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{activeMediaRow && (
|
|
||||||
<MediaUploadModal
|
|
||||||
row={activeMediaRow}
|
|
||||||
open={!!activeMediaRow}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setMediaRow(null);
|
|
||||||
}}
|
|
||||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
|
||||||
onChange={(files) => {
|
|
||||||
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
|
|
||||||
patchField(activeMediaRow.id, { mediaCount: files.length });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeDocRow && (
|
|
||||||
<CompDocDialog
|
|
||||||
row={activeDocRow}
|
|
||||||
open={!!activeDocRow}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setDocRow(null);
|
|
||||||
}}
|
|
||||||
data={completionDocs[String(activeDocRow.id)]}
|
|
||||||
onSave={(data, status) => {
|
|
||||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
|
||||||
patchField(activeDocRow.id, { docStatus: status });
|
|
||||||
}}
|
|
||||||
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
|
|
||||||
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue