shoc-frontend-new/src/app/(protected)/workorders/[id].tsx
Alexandre Brandizzi 4f99406388 chore(work-orders): merge origin/dev into pr39 integration branch
Integrate current origin/dev (vendor-core completion plus frontend
maintainability) into the work-order board shell branch.

Resolved semantic conflicts against the new frontend-maintainability
ESLint conventions introduced on dev: guard non-boolean && left
operands with Boolean()/!=null and replace raw <p> with the shared
Text component (media-upload-modal). PR #39 board shell/detail modules
and hooks preserved; merged vendor-core behavior and Text/ESLint
conventions preserved.
2026-07-24 13:45:16 -03:00

797 lines
26 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link as RouterLink, useNavigate, useParams } from "react-router";
import {
Accordion,
AccordionDetails,
AccordionSummary,
Alert,
Box,
Breadcrumbs,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Link,
Menu,
MenuItem,
Paper,
Stack,
Tab,
Tabs,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DispatchCreateModal } from "./_components/dispatch-create-modal";
import { DispatchDetailModal } from "./_components/dispatch-detail-modal";
import type { WorkOrderAuditEntry, WorkOrderComment } from "@/domain/work-orders/types/work-order";
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
import { useWorkOrderDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
import {
useAddWorkOrderComment,
useChangeWorkOrderAssignment,
useChangeWorkOrderStatus,
useUpdateWorkOrderVendor,
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
import { vendorsApi } from "@/domain/vendors/api/vendors-api";
import type { VendorPreferredContact } from "@/domain/vendors/types/vendor";
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";
interface VendorEditorState {
vendorId: string | number;
vendorName: string;
contactName: string;
preferredContact: VendorPreferredContact;
phone: string;
email: string;
notes: string;
}
function formatPhoneMask(input: string): string {
const digits = input.replace(/\D/g, "").slice(0, 10);
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
}
function isVendorEditorValid(editor: VendorEditorState): boolean {
const phoneValid = editor.phone === "" || /^\(\d{3}\) \d{3}-\d{4}$/.test(editor.phone);
const emailValid = editor.email === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(editor.email);
return (
phoneValid && emailValid && editor.notes.length <= 2000 && editor.contactName.length <= 200
);
}
function CommentTimeline({ comments }: { comments: WorkOrderComment[] }) {
const sorted = useMemo(
() =>
[...comments].sort(
(a, b) => new Date(a.createdDate).getTime() - new Date(b.createdDate).getTime(),
),
[comments],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No comments yet.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((comment) => (
<Box key={String(comment.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{comment.userName || comment.commenter || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{comment.createdDate ? formatDateTime(comment.createdDate) : ""}
</Typography>
</Stack>
<Typography variant="body2">{comment.commenttext}</Typography>
</Box>
))}
</Stack>
);
}
function AuditTimeline({ entries }: { entries: WorkOrderAuditEntry[] }) {
const sorted = useMemo(
() =>
[...entries].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
),
[entries],
);
if (!sorted.length) {
return (
<Typography
sx={{
color: "text.secondary",
}}
>
No changes recorded.
</Typography>
);
}
return (
<Stack spacing={2} className="max-h-72 overflow-y-auto">
{sorted.map((entry) => (
<Box key={String(entry.id)} className="rounded border border-border p-3">
<Stack
direction="row"
className="mb-1"
sx={{
justifyContent: "space-between",
}}
>
<Typography variant="caption" className="font-semibold">
{entry.userName || "System"}
</Typography>
<Typography
variant="caption"
sx={{
color: "text.secondary",
}}
>
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
</Typography>
</Stack>
<Typography variant="body2">
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
</Typography>
</Box>
))}
</Stack>
);
}
export default function WorkOrderViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const workOrderId = id ?? "";
const { data: wo, isLoading, error, refetch } = useWorkOrderDetail(workOrderId);
const { data: users = [] } = useWorkOrderUsers();
const statusMutation = useChangeWorkOrderStatus(workOrderId);
const assignmentMutation = useChangeWorkOrderAssignment(workOrderId);
const commentMutation = useAddWorkOrderComment(workOrderId);
const vendorMutation = useUpdateWorkOrderVendor(workOrderId);
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
const [commentText, setCommentText] = useState("");
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState<string | number | null>(null);
const [statusAnchor, setStatusAnchor] = useState<HTMLElement | null>(null);
const [assignAnchor, setAssignAnchor] = useState<HTMLElement | null>(null);
const [vendorEditor, setVendorEditor] = useState<VendorEditorState | null>(null);
const [vendorEditorLoading, setVendorEditorLoading] = useState(false);
const [vendorEditorError, setVendorEditorError] = useState("");
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("") },
);
};
const openVendorEditor = async (
event: React.MouseEvent,
vendorId: string | number,
vendorName: string,
) => {
event.stopPropagation();
setVendorEditorError("");
setVendorEditorLoading(true);
setVendorEditor({
vendorId,
vendorName,
contactName: "",
preferredContact: "Phone",
phone: "",
email: "",
notes: "",
});
try {
const vendor = await vendorsApi.getById(vendorId);
setVendorEditor({
vendorId,
vendorName: vendor.companyName || vendorName,
contactName: vendor.contactName,
preferredContact: vendor.preferredContact,
phone: vendor.phone,
email: vendor.email,
notes: vendor.notes,
});
} catch (vendorError) {
setVendorEditorError(
vendorError instanceof Error ? vendorError.message : "Unable to load vendor details",
);
} finally {
setVendorEditorLoading(false);
}
};
const saveVendorEditor = () => {
if (!vendorEditor || !isVendorEditorValid(vendorEditor)) return;
const numericWorkOrderId = Number(workOrderId);
if (!Number.isInteger(numericWorkOrderId) || numericWorkOrderId <= 0) {
setVendorEditorError("A valid work order is required");
return;
}
vendorMutation.mutate(
{
vendorId: vendorEditor.vendorId,
payload: {
workOrderId: numericWorkOrderId,
contactName: vendorEditor.contactName.trim(),
preferredContact: vendorEditor.preferredContact,
phone: vendorEditor.phone,
email: vendorEditor.email.trim(),
notes: vendorEditor.notes.trim(),
},
},
{
onSuccess: () => {
setVendorEditor(null);
void refetch();
},
onError: (vendorError) => setVendorEditorError(vendorError.message),
},
);
};
if (isLoading) {
return (
<Box className="flex justify-center p-12">
<CircularProgress />
</Box>
);
}
if (error || !wo) {
return (
<Box className="p-4">
<Alert severity="error">
{error instanceof Error ? error.message : "Work order not found."}
</Alert>
</Box>
);
}
return (
<Box className="flex flex-col gap-4 p-4">
<Breadcrumbs>
<Link component={RouterLink} to="/workorders" underline="hover">
Work Orders
</Link>
<Typography
sx={{
color: "text.primary",
}}
>
{wo.internalWONumber}
</Typography>
</Breadcrumbs>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
sx={{
justifyContent: "space-between",
alignItems: { xs: "stretch", md: "flex-start" },
}}
>
<Stack spacing={1}>
<Typography variant="h5" component="h1">
{wo.title}
</Typography>
<Chip
label={wo.status}
color={STATUS_COLORS[wo.status] ?? "default"}
onClick={(event) => setStatusAnchor(event.currentTarget)}
className="w-fit cursor-pointer"
/>
<Menu
anchorEl={statusAnchor}
open={Boolean(statusAnchor)}
onClose={() => setStatusAnchor(null)}
>
{WORK_ORDER_STATUS_OPTIONS.map((status) => (
<MenuItem
key={status}
selected={status === wo.status}
onClick={() => {
setStatusAnchor(null);
if (status !== wo.status) {
statusMutation.mutate(status);
}
}}
>
{status}
</MenuItem>
))}
</Menu>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{
flexWrap: "wrap",
}}
>
<Button variant="contained" color="success" onClick={() => setDispatchOpen(true)}>
Dispatch
</Button>
<Button variant="contained" onClick={() => navigate(`/workorders/edit/${id}`)}>
Edit
</Button>
<Button variant="outlined" onClick={() => navigate("/workorders")}>
Back to List
</Button>
</Stack>
</Stack>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Details
</Typography>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Field label="Internal WO" value={wo.internalWONumber} />
<Field label="Customer WO" value={wo.workOrderNumber || "—"} />
<Field label="Priority" value={wo.priority || "—"} />
<Field label="Location" value={wo.location || "—"} />
<Box>
<Typography
variant="caption"
sx={{
color: "text.secondary",
display: "block",
}}
>
Assigned To
</Typography>
<Button size="small" onClick={(event) => setAssignAnchor(event.currentTarget)}>
{wo.assignedTo || "Unassigned"}
</Button>
<Menu
anchorEl={assignAnchor}
open={Boolean(assignAnchor)}
onClose={() => setAssignAnchor(null)}
>
<MenuItem
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: "", userName: "" });
}}
>
Unassigned
</MenuItem>
{users.map((user) => (
<MenuItem
key={String(user.id)}
selected={wo.assignedTo === user.name}
onClick={() => {
setAssignAnchor(null);
assignmentMutation.mutate({ userId: user.id, userName: user.name });
}}
>
{user.name}
</MenuItem>
))}
</Menu>
</Box>
<Field label="Source" value={wo.source || "—"} />
<Field label="TT" value={wo.tt || "—"} />
</Box>
</Paper>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Classification
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="Problem" value={wo.problem || "—"} />
<Field label="Trade" value={wo.trade || "—"} />
<Field label="Sub-Trade" value={wo.subTrade || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Schedule
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Field
label="Created"
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
/>
<Field label="Due Date" value={wo.dueDate || "—"} />
<Field label="Scheduled" value={wo.scheduledDate || "—"} />
<Field label="Completed" value={wo.completedDate || "—"} />
</Box>
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Financial
</Typography>
</AccordionSummary>
<AccordionDetails>
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field
label="Vendor NTE"
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
/>
<Field label="Customer PO" value={wo.po || "—"} />
<Field label="Customer NTE" value="Not connected" />
</Box>
</AccordionDetails>
</Accordion>
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Dispatches ({wo.dispatches.length})
</Typography>
</AccordionSummary>
<AccordionDetails>
{wo.dispatches.length ? (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Dispatch #</TableCell>
<TableCell>Vendor</TableCell>
<TableCell>NTE</TableCell>
<TableCell>Status</TableCell>
<TableCell>Date</TableCell>
<TableCell>Wait</TableCell>
<TableCell align="right">Actions</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>
<TableCell align="right">
<Button
size="small"
onClick={(event) =>
void openVendorEditor(event, dispatch.vendorId, dispatch.vendorName)
}
disabled={dispatch.vendorId === ""}
>
Edit vendor
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography
sx={{
color: "text.secondary",
}}
>
No dispatches yet.
</Typography>
)}
</AccordionDetails>
</Accordion>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
sx={{
fontWeight: 600,
}}
>
Description
</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography>{wo.description || "No description provided."}</Typography>
</AccordionDetails>
</Accordion>
<Paper variant="outlined" className="p-4">
<Typography variant="subtitle1" className="mb-3 font-semibold">
Activity
</Typography>
<Tabs
value={activeTab}
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
className="mb-3"
>
<Tab value="customer" label={`Customer (${customerComments.length})`} />
<Tab value="internal" label={`Internal (${internalComments.length})`} />
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
</Tabs>
{activeTab === "customer" && <CommentTimeline comments={customerComments} />}
{activeTab === "internal" && <CommentTimeline comments={internalComments} />}
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
{activeTab === "internal" && (
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
<TextField
multiline
rows={2}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Type a comment..."
disabled={commentMutation.isPending}
/>
<Button
type="submit"
variant="contained"
className="self-end"
disabled={commentMutation.isPending || !commentText.trim()}
>
{commentMutation.isPending ? "Sending..." : "Send"}
</Button>
</Box>
)}
</Paper>
{dispatchOpen && (
<DispatchCreateModal
workOrder={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={() => void refetch()}
/>
)}
{dispatchDetailId != null && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
workOrderId={workOrderId}
onClose={() => setDispatchDetailId(null)}
onUpdated={() => void refetch()}
/>
)}
<Dialog
open={vendorEditor !== null}
onClose={() => {
if (!vendorMutation.isPending) setVendorEditor(null);
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Edit assigned vendor</DialogTitle>
<DialogContent>
<Stack spacing={2} className="pt-2">
<Typography variant="body2" color="text.secondary">
{vendorEditor?.vendorName}
</Typography>
{Boolean(vendorEditorError) && <Alert severity="error">{vendorEditorError}</Alert>}
{vendorEditorLoading ? (
<Box className="flex justify-center p-6">
<CircularProgress size={28} />
</Box>
) : (
<>
<TextField
label="Technician name"
value={vendorEditor?.contactName ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, contactName: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 200 } }}
/>
<TextField
select
label="Preferred contact"
value={vendorEditor?.preferredContact ?? "Phone"}
onChange={(event) =>
setVendorEditor((current) =>
current
? {
...current,
preferredContact: event.target.value as VendorPreferredContact,
}
: current,
)
}
>
<MenuItem value="Phone">Phone</MenuItem>
<MenuItem value="Email">Email</MenuItem>
<MenuItem value="Text">Text</MenuItem>
</TextField>
<TextField
label="Phone"
value={vendorEditor?.phone ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current
? { ...current, phone: formatPhoneMask(event.target.value) }
: current,
)
}
error={Boolean(
vendorEditor?.phone && !/^\(\d{3}\) \d{3}-\d{4}$/.test(vendorEditor.phone),
)}
helperText="Use (XXX) XXX-XXXX"
/>
<TextField
label="Email"
type="email"
value={vendorEditor?.email ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, email: event.target.value } : current,
)
}
/>
<TextField
label="Notes"
multiline
minRows={3}
value={vendorEditor?.notes ?? ""}
onChange={(event) =>
setVendorEditor((current) =>
current ? { ...current, notes: event.target.value } : current,
)
}
slotProps={{ htmlInput: { maxLength: 2000 } }}
/>
</>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setVendorEditor(null)} disabled={vendorMutation.isPending}>
Cancel
</Button>
<Button
variant="contained"
onClick={saveVendorEditor}
disabled={
!vendorEditor ||
vendorEditorLoading ||
vendorMutation.isPending ||
!isVendorEditorValid(vendorEditor)
}
>
{vendorMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
</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>
);
}