mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 15:09:02 +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";
|
export { DispatchDetailModal } from "./dispatch/dispatch-detail";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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