mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
feat(work-orders): add dispatch detail shell
This commit is contained in:
parent
878e1d6a92
commit
d2ff345965
12 changed files with 1002 additions and 780 deletions
|
|
@ -1,780 +1 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Stack,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useDenyUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import { DISPATCH_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddChecklistItem,
|
||||
useAddDispatchComment,
|
||||
useAddDispatchSignoff,
|
||||
useUpdateChecklistItem,
|
||||
useUpdateDispatch,
|
||||
useVerifyDispatch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import {
|
||||
formatDateTime,
|
||||
timeBetween,
|
||||
timeSince,
|
||||
toDateInputValue,
|
||||
waitTimeColor,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
interface DispatchDetailModalProps {
|
||||
dispatchId: string | number;
|
||||
workOrderId: string | number;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function DispatchDetailModal({
|
||||
dispatchId,
|
||||
workOrderId,
|
||||
onClose,
|
||||
onUpdated,
|
||||
}: DispatchDetailModalProps) {
|
||||
const { user } = useAuthContext();
|
||||
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
||||
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
status: "",
|
||||
nteAmount: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
description: "",
|
||||
});
|
||||
const [newChecklistItem, setNewChecklistItem] = useState("");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||
|
||||
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
||||
const addCommentMutation = useAddDispatchComment(dispatchId);
|
||||
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
|
||||
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
||||
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||
|
||||
const userName =
|
||||
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
|
||||
"User";
|
||||
|
||||
const beginEditing = () => {
|
||||
if (dispatch) {
|
||||
setForm({
|
||||
status: dispatch.status || "",
|
||||
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
||||
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
||||
completedDate: toDateInputValue(dispatch.completedDate),
|
||||
description: dispatch.description || "",
|
||||
});
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const verifyState = useMemo(() => {
|
||||
if (!dispatch) {
|
||||
return { canVerify: false, missing: [] as string[] };
|
||||
}
|
||||
|
||||
const checklistTotal = dispatch.checklistItems.length;
|
||||
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
|
||||
const signoffCount = dispatch.signoffs.length;
|
||||
const missing: string[] = [];
|
||||
|
||||
if (checklistTotal > 0 && checklistDone < checklistTotal) {
|
||||
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
|
||||
}
|
||||
|
||||
if (signoffCount < 2) {
|
||||
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
|
||||
if (!types.includes("Customer")) {
|
||||
missing.push("Customer sign-off");
|
||||
}
|
||||
if (!types.includes("Vendor")) {
|
||||
missing.push("Vendor sign-off");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missing,
|
||||
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSave = () => {
|
||||
updateDispatchMutation.mutate(
|
||||
{
|
||||
id: dispatchId,
|
||||
status: form.status,
|
||||
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
|
||||
scheduledDate: form.scheduledDate || null,
|
||||
completedDate: form.completedDate || null,
|
||||
description: form.description,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
onUpdated();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleComment = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCommentMutation.mutate(
|
||||
{ text: commentText.trim(), sendEmail },
|
||||
{
|
||||
onSuccess: () => setCommentText(""),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleApproveUplift = (upliftId: string | number, requestedNTE: number) => {
|
||||
const note = window.prompt("Optional approval note:", "") || "";
|
||||
if (!window.confirm(`Approve uplift to $${requestedNTE.toFixed(2)}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
approveUpliftMutation.mutate({ id: upliftId, note }, { onSuccess: onUpdated });
|
||||
};
|
||||
|
||||
const handleDenyUplift = (upliftId: string | number) => {
|
||||
const note = window.prompt("Denial reason (required):", "");
|
||||
if (!note?.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
denyUpliftMutation.mutate({ id: upliftId, note: note.trim() }, { onSuccess: onUpdated });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogContent className="flex justify-center py-12">
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dispatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLocked = isDispatchLocked(dispatch);
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
||||
<DialogTitle className="flex items-start justify-between gap-4 pr-2">
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
PO: {dispatch.poNumber} ·{" "}
|
||||
{dispatch.workOrders.length > 0
|
||||
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
||||
: `WO: ${dispatch.woNumber}`}{" "}
|
||||
· {dispatch.vendorEmail}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-6">
|
||||
<Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<TextField
|
||||
label="Status"
|
||||
select
|
||||
size="small"
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="NTE Amount"
|
||||
type="number"
|
||||
size="small"
|
||||
value={form.nteAmount}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, nteAmount: event.target.value }))
|
||||
}
|
||||
slotProps={{
|
||||
htmlInput: { step: "0.01" },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.scheduledDate}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, scheduledDate: event.target.value }))
|
||||
}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Completed Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.completedDate}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, completedDate: event.target.value }))
|
||||
}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
multiline
|
||||
rows={3}
|
||||
size="small"
|
||||
className="sm:col-span-2"
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={updateDispatchMutation.isPending}
|
||||
>
|
||||
{updateDispatchMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)}>Cancel</Button>
|
||||
</Stack>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DetailField label="Status" value={dispatch.status} />
|
||||
<DetailField
|
||||
label="NTE"
|
||||
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Scheduled"
|
||||
value={toDateInputValue(dispatch.scheduledDate) || "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Completed"
|
||||
value={toDateInputValue(dispatch.completedDate) || "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Dispatched"
|
||||
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Accepted"
|
||||
value={
|
||||
dispatch.acknowledgedAt
|
||||
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
||||
: dispatch.status === "Sent"
|
||||
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
||||
: "—"
|
||||
}
|
||||
highlight={
|
||||
dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined
|
||||
}
|
||||
/>
|
||||
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
||||
{dispatch.description && (
|
||||
<DetailField
|
||||
label="Description"
|
||||
value={dispatch.description}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
)}
|
||||
{!isLocked && (
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
|
||||
return;
|
||||
}
|
||||
updateDispatchMutation.mutate(
|
||||
{
|
||||
id: dispatchId,
|
||||
status: "Cancelled",
|
||||
nteAmount: dispatch.nteAmount,
|
||||
scheduledDate: toDateInputValue(dispatch.scheduledDate) || null,
|
||||
completedDate: toDateInputValue(dispatch.completedDate) || null,
|
||||
description: dispatch.description,
|
||||
},
|
||||
{ onSuccess: onUpdated },
|
||||
);
|
||||
}}
|
||||
>
|
||||
Cancel Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={beginEditing}>
|
||||
Edit
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{(dispatch.checklistItems.length > 0 || !isLocked) && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/
|
||||
{dispatch.checklistItems.length})
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{dispatch.checklistItems.map((item) => (
|
||||
<Stack
|
||||
key={String(item.id)}
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={item.isCompleted}
|
||||
disabled={isLocked || updateChecklistMutation.isPending}
|
||||
onChange={() =>
|
||||
updateChecklistMutation.mutate({
|
||||
itemId: item.id,
|
||||
isCompleted: !item.isCompleted,
|
||||
completedBy: userName,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Box className="flex-1">
|
||||
<Typography variant="body2">{item.itemText}</Typography>
|
||||
{item.isCompleted && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{item.completedBy}
|
||||
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
{!isLocked && (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newChecklistItem}
|
||||
onChange={(event) => setNewChecklistItem(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && newChecklistItem.trim()) {
|
||||
event.preventDefault();
|
||||
addChecklistMutation.mutate(newChecklistItem.trim(), {
|
||||
onSuccess: () => setNewChecklistItem(""),
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="Add checklist item..."
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Sign-offs
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
{(["Customer", "Vendor"] as const).map((type) => {
|
||||
const signoff = dispatch.signoffs.find((item) => item.signoffType === type);
|
||||
return (
|
||||
<Box key={type} className="flex-1 rounded border border-border p-3">
|
||||
<Typography variant="subtitle2">{type}</Typography>
|
||||
{signoff ? (
|
||||
<Box className="mt-2">
|
||||
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
||||
<img
|
||||
src={signoff.signature}
|
||||
alt="Signature"
|
||||
className="mb-2 max-h-20 max-w-full"
|
||||
/>
|
||||
)}
|
||||
{signoff.signatureMethod === "typed" && (
|
||||
<Typography variant="h6" className="italic">
|
||||
{signoff.signature}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{signoff.name}
|
||||
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
className="mt-2"
|
||||
onClick={() => setSigCaptureType(type)}
|
||||
disabled={isLocked}
|
||||
>
|
||||
{isLocked ? "Not signed" : "Collect Signature"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{dispatch.signoffs.length >= 2 && (
|
||||
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
NTE Uplift Requests
|
||||
</Typography>
|
||||
{uplifts.length === 0 ? (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No uplift requests.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{uplifts.map((uplift) => {
|
||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||
|
||||
return (
|
||||
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
||||
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
||||
{Number(uplift.delta).toFixed(2)})
|
||||
</Typography>
|
||||
</Stack>
|
||||
{uplift.vendorReason && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
className="mt-1"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.status !== "Pending" &&
|
||||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
<Box className="mt-1">
|
||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.decisionNote && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{uplift.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Tooltip
|
||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => handleApproveUplift(uplift.id, uplift.requestedNTE)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => handleDenyUplift(uplift.id)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Vendor Communication
|
||||
</Typography>
|
||||
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
||||
{dispatch.comments.length === 0 ? (
|
||||
<Typography
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
No messages yet.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{dispatch.comments.map((comment) => (
|
||||
<Box key={String(comment.id)}>
|
||||
<Stack
|
||||
direction="row"
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
<Box component="form" onSubmit={handleComment}>
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => setCommentText(event.target.value)}
|
||||
placeholder="Message vendor..."
|
||||
fullWidth
|
||||
disabled={isLocked || addCommentMutation.isPending}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mt-2"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={sendEmail}
|
||||
onChange={(event) => setSendEmail(event.target.checked)}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
}
|
||||
label="Send email"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isLocked || addCommentMutation.isPending || !commentText.trim()}
|
||||
>
|
||||
{addCommentMutation.isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{dispatch.verifiedAt ? (
|
||||
<Alert severity="success">
|
||||
Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{verifyState.missing.length > 0 && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
Missing: {verifyState.missing.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!verifyState.canVerify || verifyMutation.isPending}
|
||||
onClick={() => verifyMutation.mutate(undefined, { onSuccess: onUpdated })}
|
||||
>
|
||||
Verify & Close
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{sigCaptureType && (
|
||||
<SignatureCapture
|
||||
title={`${sigCaptureType} Sign-off`}
|
||||
onClose={() => setSigCaptureType(null)}
|
||||
onSave={(signature, method, name) => {
|
||||
addSignoffMutation.mutate(
|
||||
{
|
||||
signoffType: sigCaptureType,
|
||||
name,
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
},
|
||||
{ onSuccess: () => setSigCaptureType(null) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
highlight,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
highlight?: "green" | "yellow" | "red" | "";
|
||||
}) {
|
||||
const colorClass =
|
||||
highlight === "green"
|
||||
? "text-green-600"
|
||||
: highlight === "yellow"
|
||||
? "text-yellow-600"
|
||||
: highlight === "red"
|
||||
? "text-red-600"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Box className={className}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" className={colorClass}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
export { DispatchDetailModal } from "./dispatch/dispatch-detail";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
import type { DispatchEditFormState } from "./dispatch-edit-form";
|
||||
|
||||
export function buildDispatchEditForm(dispatch: DispatchDetail): DispatchEditFormState {
|
||||
return {
|
||||
status: dispatch.status || "",
|
||||
nteAmount: dispatch.nteAmount != null ? String(dispatch.nteAmount) : "",
|
||||
scheduledDate: toDateInputValue(dispatch.scheduledDate),
|
||||
completedDate: toDateInputValue(dispatch.completedDate),
|
||||
description: dispatch.description || "",
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { Box, Checkbox, Stack, TextField, Typography } from "@mui/material";
|
||||
import type { DispatchChecklistItem } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type DispatchChecklistSectionProps = {
|
||||
items: DispatchChecklistItem[];
|
||||
isLocked: boolean;
|
||||
isUpdating: boolean;
|
||||
userName: string;
|
||||
newItem: string;
|
||||
onNewItemChange: (value: string) => void;
|
||||
onToggleItem: (itemId: string | number, isCompleted: boolean) => void;
|
||||
onAddItem: (text: string) => void;
|
||||
};
|
||||
|
||||
export function DispatchChecklistSection({
|
||||
items,
|
||||
isLocked,
|
||||
isUpdating,
|
||||
userName,
|
||||
newItem,
|
||||
onNewItemChange,
|
||||
onToggleItem,
|
||||
onAddItem,
|
||||
}: DispatchChecklistSectionProps) {
|
||||
if (items.length === 0 && isLocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedCount = items.filter((item) => item.isCompleted).length;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Checklist ({completedCount}/{items.length})
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{items.map((item) => (
|
||||
<Stack key={String(item.id)} direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||
<Checkbox
|
||||
checked={item.isCompleted}
|
||||
disabled={isLocked || isUpdating}
|
||||
onChange={() => onToggleItem(item.id, !item.isCompleted)}
|
||||
/>
|
||||
<Box className="flex-1">
|
||||
<Typography variant="body2">{item.itemText}</Typography>
|
||||
{item.isCompleted && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.completedBy || userName}
|
||||
{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
{!isLocked && (
|
||||
<TextField
|
||||
size="small"
|
||||
value={newItem}
|
||||
onChange={(event) => onNewItemChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && newItem.trim()) {
|
||||
event.preventDefault();
|
||||
onAddItem(newItem.trim());
|
||||
}
|
||||
}}
|
||||
placeholder="Add checklist item..."
|
||||
fullWidth
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Box, IconButton, Typography } from "@mui/material";
|
||||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type DispatchDetailHeaderProps = {
|
||||
dispatch: DispatchDetail;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function DispatchDetailHeader({ dispatch, onClose }: DispatchDetailHeaderProps) {
|
||||
return (
|
||||
<Box className="flex items-start justify-between gap-4 pr-2">
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
PO: {dispatch.poNumber} ·{" "}
|
||||
{dispatch.workOrders.length > 0
|
||||
? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}`
|
||||
: `WO: ${dispatch.woNumber}`}{" "}
|
||||
· {dispatch.vendorEmail}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton aria-label="Close" onClick={onClose} size="small">
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import { Box, Button, MenuItem, Stack, TextField } from "@mui/material";
|
||||
import { DetailField } from "@/components/common/detail-field";
|
||||
import {
|
||||
DISPATCH_STATUS_OPTIONS,
|
||||
type DispatchDetail,
|
||||
} from "@/domain/work-orders/types/work-order";
|
||||
import {
|
||||
formatDateTime,
|
||||
timeBetween,
|
||||
timeSince,
|
||||
toDateInputValue,
|
||||
waitTimeColor,
|
||||
} from "@/lib/time-utils";
|
||||
|
||||
export type DispatchEditFormState = {
|
||||
status: string;
|
||||
nteAmount: string;
|
||||
scheduledDate: string;
|
||||
completedDate: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type DispatchEditFormProps = {
|
||||
form: DispatchEditFormState;
|
||||
onChange: (form: DispatchEditFormState) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
};
|
||||
|
||||
export function DispatchEditForm({
|
||||
form,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
}: DispatchEditFormProps) {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Status"
|
||||
select
|
||||
size="small"
|
||||
value={form.status}
|
||||
onChange={(event) => onChange({ ...form, status: event.target.value })}
|
||||
>
|
||||
{DISPATCH_STATUS_OPTIONS.map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="NTE Amount"
|
||||
type="number"
|
||||
size="small"
|
||||
value={form.nteAmount}
|
||||
onChange={(event) => onChange({ ...form, nteAmount: event.target.value })}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Scheduled Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.scheduledDate}
|
||||
onChange={(event) => onChange({ ...form, scheduledDate: event.target.value })}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Completed Date"
|
||||
type="date"
|
||||
size="small"
|
||||
value={form.completedDate}
|
||||
onChange={(event) => onChange({ ...form, completedDate: event.target.value })}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
multiline
|
||||
rows={3}
|
||||
size="small"
|
||||
className="sm:col-span-2"
|
||||
value={form.description}
|
||||
onChange={(event) => onChange({ ...form, description: event.target.value })}
|
||||
/>
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button variant="contained" onClick={onSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DispatchDetailsViewProps = {
|
||||
dispatch: DispatchDetail;
|
||||
isLocked: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelDispatch: () => void;
|
||||
};
|
||||
|
||||
export function DispatchDetailsView({
|
||||
dispatch,
|
||||
isLocked,
|
||||
onEdit,
|
||||
onCancelDispatch,
|
||||
}: DispatchDetailsViewProps) {
|
||||
const waitColor = waitTimeColor(dispatch.dispatchedAt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailField label="Status" value={dispatch.status} />
|
||||
<DetailField
|
||||
label="NTE"
|
||||
value={dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||
/>
|
||||
<DetailField label="Scheduled" value={toDateInputValue(dispatch.scheduledDate) || "—"} />
|
||||
<DetailField label="Completed" value={toDateInputValue(dispatch.completedDate) || "—"} />
|
||||
<DetailField
|
||||
label="Dispatched"
|
||||
value={dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||
/>
|
||||
<DetailField
|
||||
label="Accepted"
|
||||
value={
|
||||
dispatch.acknowledgedAt
|
||||
? `${formatDateTime(dispatch.acknowledgedAt)} (after ${timeBetween(dispatch.dispatchedAt, dispatch.acknowledgedAt)})`
|
||||
: dispatch.status === "Sent"
|
||||
? `Awaiting acceptance · ${timeSince(dispatch.dispatchedAt)}`
|
||||
: "—"
|
||||
}
|
||||
highlight={dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined}
|
||||
/>
|
||||
<DetailField label="Vendor Email" value={dispatch.vendorEmail || "—"} />
|
||||
{dispatch.description && (
|
||||
<DetailField label="Description" value={dispatch.description} className="sm:col-span-2" />
|
||||
)}
|
||||
{!isLocked && (
|
||||
<Stack direction="row" spacing={1} className="sm:col-span-2">
|
||||
<Button color="error" variant="outlined" onClick={onCancelDispatch}>
|
||||
Cancel Dispatch
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DispatchDetailsGrid({ children }: { children: React.ReactNode }) {
|
||||
return <Box className="grid grid-cols-1 gap-3 sm:grid-cols-2">{children}</Box>;
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { Box, Button, Chip, Stack, Typography } from "@mui/material";
|
||||
import type { DispatchSignoff } from "@/domain/work-orders/types/work-order";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type DispatchSignoffsSectionProps = {
|
||||
signoffs: DispatchSignoff[];
|
||||
isLocked: boolean;
|
||||
onCollectSignature: (type: "Customer" | "Vendor") => void;
|
||||
};
|
||||
|
||||
export function DispatchSignoffsSection({
|
||||
signoffs,
|
||||
isLocked,
|
||||
onCollectSignature,
|
||||
}: DispatchSignoffsSectionProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
Sign-offs
|
||||
</Typography>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||
{(["Customer", "Vendor"] as const).map((type) => {
|
||||
const signoff = signoffs.find((item) => item.signoffType === type);
|
||||
return (
|
||||
<Box key={type} className="flex-1 rounded border border-border p-3">
|
||||
<Typography variant="subtitle2">{type}</Typography>
|
||||
{signoff ? (
|
||||
<Box className="mt-2">
|
||||
{signoff.signatureMethod === "drawn" && signoff.signature && (
|
||||
<img
|
||||
src={signoff.signature}
|
||||
alt="Signature"
|
||||
className="mb-2 max-h-20 max-w-full"
|
||||
/>
|
||||
)}
|
||||
{signoff.signatureMethod === "typed" && (
|
||||
<Typography variant="h6" className="italic">
|
||||
{signoff.signature}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{signoff.name}
|
||||
{signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
className="mt-2"
|
||||
onClick={() => onCollectSignature(type)}
|
||||
disabled={isLocked}
|
||||
>
|
||||
{isLocked ? "Not signed" : "Collect Signature"}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
{signoffs.length >= 2 && (
|
||||
<Chip label="All Signed ✓" color="success" size="small" className="mt-2" />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Box, Button, Chip, Stack, Tooltip, Typography } from "@mui/material";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type DispatchUpliftsSectionProps = {
|
||||
uplifts: UpliftRequest[];
|
||||
isLocked: boolean;
|
||||
onApprove: (uplift: UpliftRequest) => void;
|
||||
onDeny: (uplift: UpliftRequest) => void;
|
||||
};
|
||||
|
||||
export function DispatchUpliftsSection({
|
||||
uplifts,
|
||||
isLocked,
|
||||
onApprove,
|
||||
onDeny,
|
||||
}: DispatchUpliftsSectionProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" className="mb-2 font-semibold">
|
||||
NTE Uplift Requests
|
||||
</Typography>
|
||||
{uplifts.length === 0 ? (
|
||||
<Typography color="text.secondary">No uplift requests.</Typography>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{uplifts.map((uplift) => {
|
||||
const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked;
|
||||
|
||||
return (
|
||||
<Box key={String(uplift.id)} className="rounded border border-border p-3">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Chip label={uplift.status} size="small" />
|
||||
{uplift.requiredTier === 2 && (
|
||||
<Typography variant="caption">Tier 2 · Manager approval</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
${Number(uplift.currentNTE ?? 0).toFixed(2)} → $
|
||||
{Number(uplift.requestedNTE).toFixed(2)} (+$
|
||||
{Number(uplift.delta).toFixed(2)})
|
||||
</Typography>
|
||||
</Stack>
|
||||
{uplift.vendorReason && (
|
||||
<Typography variant="body2" className="mt-1">
|
||||
<strong>Reason:</strong> {uplift.vendorReason}
|
||||
</Typography>
|
||||
)}
|
||||
{(uplift.requestedByVendorName || uplift.requestedAt) && (
|
||||
<Typography variant="body2" color="text.secondary" className="mt-1">
|
||||
Requested by {uplift.requestedByVendorName || "vendor"}
|
||||
{uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.status !== "Pending" &&
|
||||
(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && (
|
||||
<Box className="mt-1">
|
||||
{(uplift.decidedByName || uplift.decidedAt) && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{uplift.status} by {uplift.decidedByName || "—"}
|
||||
{uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{uplift.decisionNote && (
|
||||
<Typography variant="body2" className="mt-0.5">
|
||||
<strong>Note:</strong> {uplift.decisionNote}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{uplift.status === "Pending" && (
|
||||
<Stack direction="row" spacing={1} className="mt-2">
|
||||
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="success"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onApprove(uplift)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title={!canDecide ? `Requires Tier ${uplift.requiredTier} role` : ""}>
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={!canDecide}
|
||||
onClick={() => onDeny(uplift)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import { Box, Button, Checkbox, FormControlLabel, Stack, TextField } from "@mui/material";
|
||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||
import { mapDispatchCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||
import type { DispatchComment } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type DispatchVendorCommunicationProps = {
|
||||
comments: DispatchComment[];
|
||||
commentText: string;
|
||||
sendEmail: boolean;
|
||||
isLocked: boolean;
|
||||
isPending: boolean;
|
||||
onCommentTextChange: (value: string) => void;
|
||||
onSendEmailChange: (value: boolean) => void;
|
||||
onSubmit: (event: React.FormEvent) => void;
|
||||
};
|
||||
|
||||
export function DispatchVendorCommunication({
|
||||
comments,
|
||||
commentText,
|
||||
sendEmail,
|
||||
isLocked,
|
||||
isPending,
|
||||
onCommentTextChange,
|
||||
onSendEmailChange,
|
||||
onSubmit,
|
||||
}: DispatchVendorCommunicationProps) {
|
||||
return (
|
||||
<Box>
|
||||
<span className="mb-2 block text-base font-semibold">Vendor Communication</span>
|
||||
<Box className="mb-3 max-h-48 overflow-y-auto rounded border border-border p-2">
|
||||
<CommentTimeline
|
||||
items={mapDispatchCommentsToTimeline(comments)}
|
||||
emptyMessage="No messages yet."
|
||||
maxHeightClass="max-h-none"
|
||||
/>
|
||||
</Box>
|
||||
<Box component="form" onSubmit={onSubmit}>
|
||||
<TextField
|
||||
multiline
|
||||
rows={2}
|
||||
value={commentText}
|
||||
onChange={(event) => onCommentTextChange(event.target.value)}
|
||||
placeholder="Message vendor..."
|
||||
fullWidth
|
||||
disabled={isLocked || isPending}
|
||||
/>
|
||||
<Stack
|
||||
direction="row"
|
||||
className="mt-2"
|
||||
sx={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={sendEmail}
|
||||
onChange={(event) => onSendEmailChange(event.target.checked)}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
}
|
||||
label="Send email"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={isLocked || isPending || !commentText.trim()}
|
||||
>
|
||||
{isPending ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { Alert, Button, Stack, Typography } from "@mui/material";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
type DispatchVerifyFooterProps = {
|
||||
verifiedAt: string;
|
||||
verifiedBy: string;
|
||||
canVerify: boolean;
|
||||
missing: string[];
|
||||
isPending: boolean;
|
||||
onVerify: () => void;
|
||||
};
|
||||
|
||||
export function DispatchVerifyFooter({
|
||||
verifiedAt,
|
||||
verifiedBy,
|
||||
canVerify,
|
||||
missing,
|
||||
isPending,
|
||||
onVerify,
|
||||
}: DispatchVerifyFooterProps) {
|
||||
if (verifiedAt) {
|
||||
return (
|
||||
<Alert severity="success">
|
||||
Verified by {verifiedBy} on {formatDateTime(verifiedAt)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
{missing.length > 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Missing: {missing.join(", ")}
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={!canVerify || isPending}
|
||||
onClick={onVerify}
|
||||
>
|
||||
Verify & Close
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { CircularProgress, Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { SignatureCapture } from "@/components/common/signature-capture";
|
||||
import { useDispatchDetailController } from "@/app/(protected)/workorders/_hooks/use-dispatch-detail-controller";
|
||||
import { DispatchChecklistSection } from "./dispatch-checklist-section";
|
||||
import { DispatchDetailHeader } from "./dispatch-detail-header";
|
||||
import { DispatchDetailsGrid, DispatchDetailsView, DispatchEditForm } from "./dispatch-edit-form";
|
||||
import { DispatchSignoffsSection } from "./dispatch-signoffs-section";
|
||||
import { DispatchUpliftsSection } from "./dispatch-uplifts-section";
|
||||
import { DispatchVendorCommunication } from "./dispatch-vendor-communication";
|
||||
import { DispatchVerifyFooter } from "./dispatch-verify-footer";
|
||||
import { UpliftDecisionDialog } from "./uplift-decision-dialog";
|
||||
|
||||
interface DispatchDetailModalProps {
|
||||
dispatchId: string | number;
|
||||
workOrderId: string | number;
|
||||
onClose: () => void;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
export function DispatchDetailModal({
|
||||
dispatchId,
|
||||
workOrderId,
|
||||
onClose,
|
||||
}: DispatchDetailModalProps) {
|
||||
const controller = useDispatchDetailController(dispatchId, workOrderId);
|
||||
|
||||
if (controller.isLoading) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogContent className="flex justify-center py-12">
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!controller.dispatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { dispatch } = controller;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onClose={onClose} maxWidth="md" fullWidth scroll="paper">
|
||||
<DialogTitle>
|
||||
<DispatchDetailHeader dispatch={dispatch} onClose={onClose} />
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className="flex flex-col gap-6">
|
||||
<DispatchDetailsGrid>
|
||||
{controller.editing ? (
|
||||
<DispatchEditForm
|
||||
form={controller.form}
|
||||
onChange={controller.setForm}
|
||||
onSave={controller.handleSave}
|
||||
onCancel={() => controller.setEditing(false)}
|
||||
isSaving={controller.updateDispatchMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<DispatchDetailsView
|
||||
dispatch={dispatch}
|
||||
isLocked={controller.isLocked}
|
||||
onEdit={controller.beginEditing}
|
||||
onCancelDispatch={() => controller.handleCancelDispatch(dispatch)}
|
||||
/>
|
||||
)}
|
||||
</DispatchDetailsGrid>
|
||||
|
||||
{(dispatch.checklistItems.length > 0 || !controller.isLocked) && (
|
||||
<DispatchChecklistSection
|
||||
items={dispatch.checklistItems}
|
||||
isLocked={controller.isLocked}
|
||||
isUpdating={controller.updateChecklistMutation.isPending}
|
||||
userName={controller.userName}
|
||||
newItem={controller.newChecklistItem}
|
||||
onNewItemChange={controller.setNewChecklistItem}
|
||||
onToggleItem={(itemId, isCompleted) =>
|
||||
controller.updateChecklistMutation.mutate({
|
||||
itemId,
|
||||
isCompleted,
|
||||
completedBy: controller.userName,
|
||||
})
|
||||
}
|
||||
onAddItem={(text) =>
|
||||
controller.addChecklistMutation.mutate(text, {
|
||||
onSuccess: () => controller.setNewChecklistItem(""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DispatchSignoffsSection
|
||||
signoffs={dispatch.signoffs}
|
||||
isLocked={controller.isLocked}
|
||||
onCollectSignature={controller.setSigCaptureType}
|
||||
/>
|
||||
|
||||
<DispatchUpliftsSection
|
||||
uplifts={controller.uplifts}
|
||||
isLocked={controller.isLocked}
|
||||
onApprove={(uplift) => controller.setUpliftAction({ type: "approve", uplift })}
|
||||
onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })}
|
||||
/>
|
||||
|
||||
<DispatchVendorCommunication
|
||||
comments={dispatch.comments}
|
||||
commentText={controller.commentText}
|
||||
sendEmail={controller.sendEmail}
|
||||
isLocked={controller.isLocked}
|
||||
isPending={controller.addCommentMutation.isPending}
|
||||
onCommentTextChange={controller.setCommentText}
|
||||
onSendEmailChange={controller.setSendEmail}
|
||||
onSubmit={controller.handleComment}
|
||||
/>
|
||||
|
||||
<DispatchVerifyFooter
|
||||
verifiedAt={dispatch.verifiedAt}
|
||||
verifiedBy={dispatch.verifiedBy}
|
||||
canVerify={controller.verifyState.canVerify}
|
||||
missing={controller.verifyState.missing}
|
||||
isPending={controller.verifyMutation.isPending}
|
||||
onVerify={() => controller.verifyMutation.mutate(undefined)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{controller.sigCaptureType && (
|
||||
<SignatureCapture
|
||||
title={`${controller.sigCaptureType} Sign-off`}
|
||||
onClose={() => controller.setSigCaptureType(null)}
|
||||
onSave={(signature, method, name) => {
|
||||
controller.addSignoffMutation.mutate(
|
||||
{
|
||||
signoffType: controller.sigCaptureType!,
|
||||
name,
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
},
|
||||
{ onSuccess: () => controller.setSigCaptureType(null) },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controller.upliftAction && (
|
||||
<UpliftDecisionDialog
|
||||
open
|
||||
type={controller.upliftAction.type}
|
||||
requestedNTE={controller.upliftAction.uplift.requestedNTE}
|
||||
currentNTE={controller.upliftAction.uplift.currentNTE}
|
||||
onClose={() => controller.setUpliftAction(null)}
|
||||
onConfirm={controller.handleUpliftConfirm}
|
||||
isPending={
|
||||
controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
type UpliftDecisionDialogProps = {
|
||||
open: boolean;
|
||||
type: "approve" | "deny";
|
||||
requestedNTE: number;
|
||||
currentNTE: number | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (note: string) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export function UpliftDecisionDialog({
|
||||
open,
|
||||
type,
|
||||
requestedNTE,
|
||||
currentNTE,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: UpliftDecisionDialogProps) {
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const handleClose = () => {
|
||||
setNote("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(note.trim());
|
||||
setNote("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>
|
||||
{type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$
|
||||
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={type === "deny" ? "Denial reason (required)" : "Optional note"}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color={type === "approve" ? "success" : "error"}
|
||||
disabled={type === "deny" ? !note.trim() || isPending : isPending}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useApproveUplift,
|
||||
useDenyUplift,
|
||||
useUpliftsForDispatch,
|
||||
} from "@/domain/uplifts/use-cases/use-uplift-actions";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import type { DispatchDetail } from "@/domain/work-orders/types/work-order";
|
||||
import { useDispatchDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||
import {
|
||||
useAddChecklistItem,
|
||||
useAddDispatchComment,
|
||||
useAddDispatchSignoff,
|
||||
useUpdateChecklistItem,
|
||||
useUpdateDispatch,
|
||||
useVerifyDispatch,
|
||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||
import { isDispatchLocked } from "@/domain/work-orders/utils/is-dispatch-locked";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
import { toDateInputValue } from "@/lib/time-utils";
|
||||
import { buildDispatchEditForm } from "@/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form";
|
||||
import type { DispatchEditFormState } from "@/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form";
|
||||
|
||||
export function useDispatchDetailController(
|
||||
dispatchId: string | number,
|
||||
workOrderId: string | number,
|
||||
) {
|
||||
const { user } = useAuthContext();
|
||||
const { data: dispatch, isLoading } = useDispatchDetail(dispatchId);
|
||||
const { data: uplifts = [] } = useUpliftsForDispatch(dispatchId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<DispatchEditFormState>({
|
||||
status: "",
|
||||
nteAmount: "",
|
||||
scheduledDate: "",
|
||||
completedDate: "",
|
||||
description: "",
|
||||
});
|
||||
const [newChecklistItem, setNewChecklistItem] = useState("");
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [sendEmail, setSendEmail] = useState(true);
|
||||
const [sigCaptureType, setSigCaptureType] = useState<string | null>(null);
|
||||
const [upliftAction, setUpliftAction] = useState<{
|
||||
type: "approve" | "deny";
|
||||
uplift: UpliftRequest;
|
||||
} | null>(null);
|
||||
|
||||
const updateDispatchMutation = useUpdateDispatch(dispatchId, workOrderId);
|
||||
const addCommentMutation = useAddDispatchComment(dispatchId);
|
||||
const updateChecklistMutation = useUpdateChecklistItem(dispatchId);
|
||||
const addChecklistMutation = useAddChecklistItem(dispatchId);
|
||||
const addSignoffMutation = useAddDispatchSignoff(dispatchId);
|
||||
const verifyMutation = useVerifyDispatch(dispatchId, workOrderId);
|
||||
const approveUpliftMutation = useApproveUplift(dispatchId);
|
||||
const denyUpliftMutation = useDenyUplift(dispatchId);
|
||||
|
||||
const userName =
|
||||
(typeof user?.name === "string" ? user.name : undefined) ??
|
||||
(typeof user?.fullname === "string" ? user.fullname : undefined) ??
|
||||
"User";
|
||||
|
||||
const isLocked = dispatch ? isDispatchLocked(dispatch) : false;
|
||||
|
||||
const verifyState = useMemo(() => {
|
||||
if (!dispatch) {
|
||||
return { canVerify: false, missing: [] as string[] };
|
||||
}
|
||||
|
||||
const checklistTotal = dispatch.checklistItems.length;
|
||||
const checklistDone = dispatch.checklistItems.filter((item) => item.isCompleted).length;
|
||||
const signoffCount = dispatch.signoffs.length;
|
||||
const missing: string[] = [];
|
||||
|
||||
if (checklistTotal > 0 && checklistDone < checklistTotal) {
|
||||
missing.push(`${checklistTotal - checklistDone} checklist item(s)`);
|
||||
}
|
||||
|
||||
if (signoffCount < 2) {
|
||||
const types = dispatch.signoffs.map((signoff) => signoff.signoffType);
|
||||
if (!types.includes("Customer")) {
|
||||
missing.push("Customer sign-off");
|
||||
}
|
||||
if (!types.includes("Vendor")) {
|
||||
missing.push("Vendor sign-off");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missing,
|
||||
canVerify: missing.length === 0 && (checklistTotal > 0 || signoffCount >= 2),
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const beginEditing = () => {
|
||||
if (dispatch) {
|
||||
setForm(buildDispatchEditForm(dispatch));
|
||||
}
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateDispatchMutation.mutate(
|
||||
{
|
||||
id: dispatchId,
|
||||
status: form.status,
|
||||
nteAmount: form.nteAmount ? parseFloat(form.nteAmount) : null,
|
||||
scheduledDate: form.scheduledDate || null,
|
||||
completedDate: form.completedDate || null,
|
||||
description: form.description,
|
||||
},
|
||||
{ onSuccess: () => setEditing(false) },
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancelDispatch = (current: DispatchDetail) => {
|
||||
if (!window.confirm("Are you sure you want to cancel this dispatch?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDispatchMutation.mutate({
|
||||
id: dispatchId,
|
||||
status: "Cancelled",
|
||||
nteAmount: current.nteAmount,
|
||||
scheduledDate: toDateInputValue(current.scheduledDate) || null,
|
||||
completedDate: toDateInputValue(current.completedDate) || null,
|
||||
description: current.description,
|
||||
});
|
||||
};
|
||||
|
||||
const handleComment = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!commentText.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
addCommentMutation.mutate(
|
||||
{ text: commentText.trim(), sendEmail },
|
||||
{ onSuccess: () => setCommentText("") },
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpliftConfirm = (note: string) => {
|
||||
if (!upliftAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
denyUpliftMutation.mutate(
|
||||
{ id: upliftAction.uplift.id, note },
|
||||
{ onSuccess: () => setUpliftAction(null) },
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
dispatch,
|
||||
isLoading,
|
||||
uplifts,
|
||||
editing,
|
||||
form,
|
||||
setForm,
|
||||
newChecklistItem,
|
||||
setNewChecklistItem,
|
||||
commentText,
|
||||
setCommentText,
|
||||
sendEmail,
|
||||
setSendEmail,
|
||||
sigCaptureType,
|
||||
setSigCaptureType,
|
||||
upliftAction,
|
||||
setUpliftAction,
|
||||
isLocked,
|
||||
userName,
|
||||
verifyState,
|
||||
beginEditing,
|
||||
handleSave,
|
||||
handleCancelDispatch,
|
||||
handleComment,
|
||||
handleUpliftConfirm,
|
||||
setEditing,
|
||||
updateDispatchMutation,
|
||||
updateChecklistMutation,
|
||||
addChecklistMutation,
|
||||
addSignoffMutation,
|
||||
verifyMutation,
|
||||
approveUpliftMutation,
|
||||
denyUpliftMutation,
|
||||
addCommentMutation,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue