feat(work-orders): add dispatch detail shell

This commit is contained in:
Arthur Bassi 2026-07-17 12:08:44 -03:00
parent 878e1d6a92
commit d2ff345965
12 changed files with 1002 additions and 780 deletions

View file

@ -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";

View file

@ -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 || "",
};
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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
}
/>
)}
</>
);
}

View file

@ -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>
);
}

View file

@ -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,
};
}