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