From d2ff345965aff1d9fe1b81227b0ed61509fbb99b Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 12:08:44 -0300 Subject: [PATCH 1/4] feat(work-orders): add dispatch detail shell --- .../_components/dispatch-detail-modal.tsx | 781 +----------------- .../build-dispatch-edit-form.ts | 13 + .../dispatch-checklist-section.tsx | 75 ++ .../dispatch-detail-header.tsx | 30 + .../dispatch-detail/dispatch-edit-form.tsx | 155 ++++ .../dispatch-signoffs-section.tsx | 66 ++ .../dispatch-uplifts-section.tsx | 107 +++ .../dispatch-vendor-communication.tsx | 73 ++ .../dispatch-verify-footer.tsx | 46 ++ .../dispatch/dispatch-detail/index.tsx | 161 ++++ .../uplift-decision-dialog.tsx | 78 ++ .../_hooks/use-dispatch-detail-controller.ts | 197 +++++ 12 files changed, 1002 insertions(+), 780 deletions(-) create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx create mode 100644 src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts diff --git a/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx index c68475e9..d155ef33 100644 --- a/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx +++ b/src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx @@ -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(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 ( - - - - - - ); - } - - if (!dispatch) { - return null; - } - - const isLocked = isDispatchLocked(dispatch); - const waitColor = waitTimeColor(dispatch.dispatchedAt); - - return ( - <> - - - - - {dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName} - - - PO: {dispatch.poNumber} ·{" "} - {dispatch.workOrders.length > 0 - ? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}` - : `WO: ${dispatch.woNumber}`}{" "} - · {dispatch.vendorEmail} - - - - - - - - - - {editing ? ( - <> - - setForm((current) => ({ ...current, status: event.target.value })) - } - > - {DISPATCH_STATUS_OPTIONS.map((status) => ( - - {status} - - ))} - - - setForm((current) => ({ ...current, nteAmount: event.target.value })) - } - slotProps={{ - htmlInput: { step: "0.01" }, - }} - /> - - setForm((current) => ({ ...current, scheduledDate: event.target.value })) - } - slotProps={{ inputLabel: { shrink: true } }} - /> - - setForm((current) => ({ ...current, completedDate: event.target.value })) - } - slotProps={{ inputLabel: { shrink: true } }} - /> - - setForm((current) => ({ ...current, description: event.target.value })) - } - /> - - - - - - ) : ( - <> - - - - - - - - {dispatch.description && ( - - )} - {!isLocked && ( - - - - - )} - - )} - - - {(dispatch.checklistItems.length > 0 || !isLocked) && ( - - - Checklist ({dispatch.checklistItems.filter((item) => item.isCompleted).length}/ - {dispatch.checklistItems.length}) - - - {dispatch.checklistItems.map((item) => ( - - - updateChecklistMutation.mutate({ - itemId: item.id, - isCompleted: !item.isCompleted, - completedBy: userName, - }) - } - /> - - {item.itemText} - {item.isCompleted && ( - - {item.completedBy} - {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} - - )} - - - ))} - - {!isLocked && ( - 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" - /> - )} - - )} - - - - Sign-offs - - - {(["Customer", "Vendor"] as const).map((type) => { - const signoff = dispatch.signoffs.find((item) => item.signoffType === type); - return ( - - {type} - {signoff ? ( - - {signoff.signatureMethod === "drawn" && signoff.signature && ( - Signature - )} - {signoff.signatureMethod === "typed" && ( - - {signoff.signature} - - )} - - {signoff.name} - {signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""} - - - ) : ( - - )} - - ); - })} - - {dispatch.signoffs.length >= 2 && ( - - )} - - - - - NTE Uplift Requests - - {uplifts.length === 0 ? ( - - No uplift requests. - - ) : ( - - {uplifts.map((uplift) => { - const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; - - return ( - - - - {uplift.requiredTier === 2 && ( - Tier 2 · Manager approval - )} - - ${Number(uplift.currentNTE ?? 0).toFixed(2)} → $ - {Number(uplift.requestedNTE).toFixed(2)} (+$ - {Number(uplift.delta).toFixed(2)}) - - - {uplift.vendorReason && ( - - Reason: {uplift.vendorReason} - - )} - {(uplift.requestedByVendorName || uplift.requestedAt) && ( - - Requested by {uplift.requestedByVendorName || "vendor"} - {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} - - )} - {uplift.status !== "Pending" && - (uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( - - {(uplift.decidedByName || uplift.decidedAt) && ( - - {uplift.status} by {uplift.decidedByName || "—"} - {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} - - )} - {uplift.decisionNote && ( - - Note: {uplift.decisionNote} - - )} - - )} - {uplift.status === "Pending" && ( - - - - - - - - - - - - - )} - - ); - })} - - )} - - - - - Vendor Communication - - - {dispatch.comments.length === 0 ? ( - - No messages yet. - - ) : ( - - {dispatch.comments.map((comment) => ( - - - - {comment.userName || comment.commenter || "System"} - - - {comment.createdDate ? formatDateTime(comment.createdDate) : ""} - - - {comment.commenttext} - - ))} - - )} - - - setCommentText(event.target.value)} - placeholder="Message vendor..." - fullWidth - disabled={isLocked || addCommentMutation.isPending} - /> - - setSendEmail(event.target.checked)} - disabled={isLocked} - /> - } - label="Send email" - /> - - - - - - {dispatch.verifiedAt ? ( - - Verified by {dispatch.verifiedBy} on {formatDateTime(dispatch.verifiedAt)} - - ) : ( - - {verifyState.missing.length > 0 && ( - - Missing: {verifyState.missing.join(", ")} - - )} - - - )} - - - {sigCaptureType && ( - 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 ( - - - {label} - - - {value} - - - ); -} +export { DispatchDetailModal } from "./dispatch/dispatch-detail"; diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts new file mode 100644 index 00000000..0573c385 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/build-dispatch-edit-form.ts @@ -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 || "", + }; +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx new file mode 100644 index 00000000..efb88761 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -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 ( + + + Checklist ({completedCount}/{items.length}) + + + {items.map((item) => ( + + onToggleItem(item.id, !item.isCompleted)} + /> + + {item.itemText} + {item.isCompleted && ( + + {item.completedBy || userName} + {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} + + )} + + + ))} + + {!isLocked && ( + 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" + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx new file mode 100644 index 00000000..a345fdf4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-detail-header.tsx @@ -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 ( + + + + {dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName} + + + PO: {dispatch.poNumber} ·{" "} + {dispatch.workOrders.length > 0 + ? `${dispatch.workOrders.length} WO(s): ${dispatch.workOrders.map((wo) => wo.woNumber).join(", ")}` + : `WO: ${dispatch.woNumber}`}{" "} + · {dispatch.vendorEmail} + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx new file mode 100644 index 00000000..a34baa47 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx @@ -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 ( + <> + onChange({ ...form, status: event.target.value })} + > + {DISPATCH_STATUS_OPTIONS.map((status) => ( + + {status} + + ))} + + onChange({ ...form, nteAmount: event.target.value })} + slotProps={{ htmlInput: { step: "0.01" } }} + /> + onChange({ ...form, scheduledDate: event.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + /> + onChange({ ...form, completedDate: event.target.value })} + slotProps={{ inputLabel: { shrink: true } }} + /> + onChange({ ...form, description: event.target.value })} + /> + + + + + + ); +} + +type DispatchDetailsViewProps = { + dispatch: DispatchDetail; + isLocked: boolean; + onEdit: () => void; + onCancelDispatch: () => void; +}; + +export function DispatchDetailsView({ + dispatch, + isLocked, + onEdit, + onCancelDispatch, +}: DispatchDetailsViewProps) { + const waitColor = waitTimeColor(dispatch.dispatchedAt); + + return ( + <> + + + + + + + + {dispatch.description && ( + + )} + {!isLocked && ( + + + + + )} + + ); +} + +export function DispatchDetailsGrid({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx new file mode 100644 index 00000000..57288b9b --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx @@ -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 ( + + + Sign-offs + + + {(["Customer", "Vendor"] as const).map((type) => { + const signoff = signoffs.find((item) => item.signoffType === type); + return ( + + {type} + {signoff ? ( + + {signoff.signatureMethod === "drawn" && signoff.signature && ( + Signature + )} + {signoff.signatureMethod === "typed" && ( + + {signoff.signature} + + )} + + {signoff.name} + {signoff.signedAt ? ` · ${formatDateTime(signoff.signedAt)}` : ""} + + + ) : ( + + )} + + ); + })} + + {signoffs.length >= 2 && ( + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx new file mode 100644 index 00000000..80a78b8b --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-uplifts-section.tsx @@ -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 ( + + + NTE Uplift Requests + + {uplifts.length === 0 ? ( + No uplift requests. + ) : ( + + {uplifts.map((uplift) => { + const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; + + return ( + + + + {uplift.requiredTier === 2 && ( + Tier 2 · Manager approval + )} + + ${Number(uplift.currentNTE ?? 0).toFixed(2)} → $ + {Number(uplift.requestedNTE).toFixed(2)} (+$ + {Number(uplift.delta).toFixed(2)}) + + + {uplift.vendorReason && ( + + Reason: {uplift.vendorReason} + + )} + {(uplift.requestedByVendorName || uplift.requestedAt) && ( + + Requested by {uplift.requestedByVendorName || "vendor"} + {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} + + )} + {uplift.status !== "Pending" && + (uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( + + {(uplift.decidedByName || uplift.decidedAt) && ( + + {uplift.status} by {uplift.decidedByName || "—"} + {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} + + )} + {uplift.decisionNote && ( + + Note: {uplift.decisionNote} + + )} + + )} + {uplift.status === "Pending" && ( + + + + + + + + + + + + + )} + + ); + })} + + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx new file mode 100644 index 00000000..74cf4e1a --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-vendor-communication.tsx @@ -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 ( + + Vendor Communication + + + + + onCommentTextChange(event.target.value)} + placeholder="Message vendor..." + fullWidth + disabled={isLocked || isPending} + /> + + onSendEmailChange(event.target.checked)} + disabled={isLocked} + /> + } + label="Send email" + /> + + + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx new file mode 100644 index 00000000..7ee7cb1e --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-verify-footer.tsx @@ -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 ( + + Verified by {verifiedBy} on {formatDateTime(verifiedAt)} + + ); + } + + return ( + + {missing.length > 0 && ( + + Missing: {missing.join(", ")} + + )} + + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx new file mode 100644 index 00000000..8a9a4fb4 --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -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 ( + + + + + + ); + } + + if (!controller.dispatch) { + return null; + } + + const { dispatch } = controller; + + return ( + <> + + + + + + + + {controller.editing ? ( + controller.setEditing(false)} + isSaving={controller.updateDispatchMutation.isPending} + /> + ) : ( + controller.handleCancelDispatch(dispatch)} + /> + )} + + + {(dispatch.checklistItems.length > 0 || !controller.isLocked) && ( + + controller.updateChecklistMutation.mutate({ + itemId, + isCompleted, + completedBy: controller.userName, + }) + } + onAddItem={(text) => + controller.addChecklistMutation.mutate(text, { + onSuccess: () => controller.setNewChecklistItem(""), + }) + } + /> + )} + + + + controller.setUpliftAction({ type: "approve", uplift })} + onDeny={(uplift) => controller.setUpliftAction({ type: "deny", uplift })} + /> + + + + controller.verifyMutation.mutate(undefined)} + /> + + + + {controller.sigCaptureType && ( + controller.setSigCaptureType(null)} + onSave={(signature, method, name) => { + controller.addSignoffMutation.mutate( + { + signoffType: controller.sigCaptureType!, + name, + signature, + signatureMethod: method, + }, + { onSuccess: () => controller.setSigCaptureType(null) }, + ); + }} + /> + )} + + {controller.upliftAction && ( + controller.setUpliftAction(null)} + onConfirm={controller.handleUpliftConfirm} + isPending={ + controller.approveUpliftMutation.isPending || controller.denyUpliftMutation.isPending + } + /> + )} + + ); +} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx new file mode 100644 index 00000000..c8e7cf2f --- /dev/null +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/uplift-decision-dialog.tsx @@ -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 ( + + + {type === "approve" ? `Approve uplift to $${requestedNTE.toFixed(2)}?` : "Deny Uplift"} + + + {type === "approve" && ( + + ${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$ + {(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)}) + + )} + setNote(event.target.value)} + /> + + + + + + + ); +} diff --git a/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts new file mode 100644 index 00000000..63f4eded --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-dispatch-detail-controller.ts @@ -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({ + status: "", + nteAmount: "", + scheduledDate: "", + completedDate: "", + description: "", + }); + const [newChecklistItem, setNewChecklistItem] = useState(""); + const [commentText, setCommentText] = useState(""); + const [sendEmail, setSendEmail] = useState(true); + const [sigCaptureType, setSigCaptureType] = useState(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, + }; +} From 40c6e3cb3d7d55702b73ea53d6bde805137a4872 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 10:23:50 -0300 Subject: [PATCH 2/4] fix(work-orders): never attribute checklist completion to viewer --- .../dispatch/dispatch-detail/dispatch-checklist-section.tsx | 6 ++---- .../_components/dispatch/dispatch-detail/index.tsx | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx index efb88761..1eb41674 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -6,7 +6,6 @@ type DispatchChecklistSectionProps = { items: DispatchChecklistItem[]; isLocked: boolean; isUpdating: boolean; - userName: string; newItem: string; onNewItemChange: (value: string) => void; onToggleItem: (itemId: string | number, isCompleted: boolean) => void; @@ -17,7 +16,6 @@ export function DispatchChecklistSection({ items, isLocked, isUpdating, - userName, newItem, onNewItemChange, onToggleItem, @@ -44,9 +42,9 @@ export function DispatchChecklistSection({ /> {item.itemText} - {item.isCompleted && ( + {item.isCompleted && (item.completedBy || item.completedAt) && ( - {item.completedBy || userName} + {item.completedBy?.trim() || "Unknown"} {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx index 8a9a4fb4..487cfcd5 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -72,7 +72,6 @@ export function DispatchDetailModal({ items={dispatch.checklistItems} isLocked={controller.isLocked} isUpdating={controller.updateChecklistMutation.isPending} - userName={controller.userName} newItem={controller.newChecklistItem} onNewItemChange={controller.setNewChecklistItem} onToggleItem={(itemId, isCompleted) => From 01351b3f336305f707d8e6dc18883cb73a876686 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Wed, 22 Jul 2026 13:28:12 -0300 Subject: [PATCH 3/4] test(work-orders): cover missing checklist completedBy provenance Regression: null/empty/whitespace completedBy shows Unknown, never the viewer. --- .../dispatch-checklist-section.test.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/test/app/(protected)/workorders/dispatch-checklist-section.test.tsx diff --git a/src/test/app/(protected)/workorders/dispatch-checklist-section.test.tsx b/src/test/app/(protected)/workorders/dispatch-checklist-section.test.tsx new file mode 100644 index 00000000..194aff65 --- /dev/null +++ b/src/test/app/(protected)/workorders/dispatch-checklist-section.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { DispatchChecklistSection } from "@/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section"; +import type { DispatchChecklistItem } from "@/domain/work-orders/types/work-order"; + +const VIEWER_NAME = "Alice Viewer"; + +function renderChecklist(items: DispatchChecklistItem[]) { + return render( + , + ); +} + +describe("DispatchChecklistSection completedBy provenance", () => { + it.each([ + { label: "null", completedBy: null }, + { label: "empty", completedBy: "" }, + { label: "whitespace", completedBy: " " }, + ])("shows Unknown (never the viewer) when completedBy is $label", ({ completedBy }) => { + renderChecklist([ + { + id: 1, + itemText: "Verify site access", + isCompleted: true, + completedBy, + completedAt: "2026-07-01T15:30:00Z", + }, + ]); + + expect(screen.getByText(/Unknown/)).toBeInTheDocument(); + expect(screen.queryByText(VIEWER_NAME)).not.toBeInTheDocument(); + expect(screen.queryByText(/Alice/)).not.toBeInTheDocument(); + }); + + it("renders the recorded completedBy when present", () => { + renderChecklist([ + { + id: 2, + itemText: "Collect photos", + isCompleted: true, + completedBy: "Jordan Tech", + completedAt: "2026-07-01T15:30:00Z", + }, + ]); + + expect(screen.getByText(/Jordan Tech/)).toBeInTheDocument(); + expect(screen.queryByText(/Unknown/)).not.toBeInTheDocument(); + expect(screen.queryByText(VIEWER_NAME)).not.toBeInTheDocument(); + }); +}); From 09ed3693f63e57006c8b7d0acd2f1d4e08c959be Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 18:51:50 -0300 Subject: [PATCH 4/4] refactor: enforce dispatch detail quality gates --- scripts/governance-baseline.json | 5 - .../dispatch-checklist-section.tsx | 2 +- .../dispatch-detail/dispatch-edit-form.tsx | 2 +- .../dispatch-signoffs-section.tsx | 2 +- .../dispatch-uplifts-section.tsx | 157 +++++++++--------- .../dispatch/dispatch-detail/index.tsx | 4 +- .../_hooks/use-dispatch-detail-controller.ts | 56 +++---- 7 files changed, 113 insertions(+), 115 deletions(-) diff --git a/scripts/governance-baseline.json b/scripts/governance-baseline.json index d82858fd..93a682d9 100644 --- a/scripts/governance-baseline.json +++ b/scripts/governance-baseline.json @@ -8,11 +8,6 @@ "maxLines": 797, "reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components." }, - { - "path": "src/app/(protected)/workorders/_components/dispatch-detail-modal.tsx", - "maxLines": 782, - "reason": "Dispatch detail modal; oversized legacy component pending extraction of sections/fields." - }, { "path": "src/app/(protected)/vendors/index.tsx", "maxLines": 612, diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx index 1eb41674..03ff527a 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-checklist-section.tsx @@ -42,7 +42,7 @@ export function DispatchChecklistSection({ /> {item.itemText} - {item.isCompleted && (item.completedBy || item.completedAt) && ( + {item.isCompleted && Boolean(item.completedBy || item.completedAt) && ( {item.completedBy?.trim() || "Unknown"} {item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx index a34baa47..1a0a27f2 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-edit-form.tsx @@ -133,7 +133,7 @@ export function DispatchDetailsView({ highlight={dispatch.status === "Sent" && !dispatch.acknowledgedAt ? waitColor : undefined} /> - {dispatch.description && ( + {Boolean(dispatch.description) && ( )} {!isLocked && ( diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx index 57288b9b..00c5e177 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/dispatch-signoffs-section.tsx @@ -26,7 +26,7 @@ export function DispatchSignoffsSection({ {type} {signoff ? ( - {signoff.signatureMethod === "drawn" && signoff.signature && ( + {signoff.signatureMethod === "drawn" && Boolean(signoff.signature) && ( Signature void; }; +type UpliftCardProps = Omit & { + uplift: UpliftRequest; +}; + +function UpliftCard({ uplift, isLocked, onApprove, onDeny }: UpliftCardProps) { + const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; + + return ( + + + + {uplift.requiredTier === 2 && ( + Tier 2 · Manager approval + )} + + ${Number(uplift.currentNTE ?? 0).toFixed(2)} → ${Number(uplift.requestedNTE).toFixed(2)}{" "} + (+${Number(uplift.delta).toFixed(2)}) + + + {Boolean(uplift.vendorReason) && ( + + Reason: {uplift.vendorReason} + + )} + {Boolean(uplift.requestedByVendorName || uplift.requestedAt) && ( + + Requested by {uplift.requestedByVendorName || "vendor"} + {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} + + )} + {uplift.status !== "Pending" && + Boolean(uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( + + {Boolean(uplift.decidedByName || uplift.decidedAt) && ( + + {uplift.status} by {uplift.decidedByName || "—"} + {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} + + )} + {Boolean(uplift.decisionNote) && ( + + Note: {uplift.decisionNote} + + )} + + )} + {uplift.status === "Pending" && ( + + {(["approve", "deny"] as const).map((action) => ( + + + + + + ))} + + )} + + ); +} + export function DispatchUpliftsSection({ uplifts, isLocked, @@ -24,82 +96,15 @@ export function DispatchUpliftsSection({ No uplift requests. ) : ( - {uplifts.map((uplift) => { - const canDecide = uplift.canDecide && uplift.status === "Pending" && !isLocked; - - return ( - - - - {uplift.requiredTier === 2 && ( - Tier 2 · Manager approval - )} - - ${Number(uplift.currentNTE ?? 0).toFixed(2)} → $ - {Number(uplift.requestedNTE).toFixed(2)} (+$ - {Number(uplift.delta).toFixed(2)}) - - - {uplift.vendorReason && ( - - Reason: {uplift.vendorReason} - - )} - {(uplift.requestedByVendorName || uplift.requestedAt) && ( - - Requested by {uplift.requestedByVendorName || "vendor"} - {uplift.requestedAt ? ` · ${formatDateTime(uplift.requestedAt)}` : ""} - - )} - {uplift.status !== "Pending" && - (uplift.decidedByName || uplift.decidedAt || uplift.decisionNote) && ( - - {(uplift.decidedByName || uplift.decidedAt) && ( - - {uplift.status} by {uplift.decidedByName || "—"} - {uplift.decidedAt ? ` · ${formatDateTime(uplift.decidedAt)}` : ""} - - )} - {uplift.decisionNote && ( - - Note: {uplift.decisionNote} - - )} - - )} - {uplift.status === "Pending" && ( - - - - - - - - - - - - - )} - - ); - })} + {uplifts.map((uplift) => ( + + ))} )} diff --git a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx index 487cfcd5..7189fe1e 100644 --- a/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx +++ b/src/app/(protected)/workorders/_components/dispatch/dispatch-detail/index.tsx @@ -124,7 +124,7 @@ export function DispatchDetailModal({ - {controller.sigCaptureType && ( + {controller.sigCaptureType != null && ( controller.setSigCaptureType(null)} @@ -142,7 +142,7 @@ export function DispatchDetailModal({ /> )} - {controller.upliftAction && ( + {controller.upliftAction != null && ( item.isCompleted).length; + const signoffTypes = new Set(dispatch.signoffs.map((signoff) => signoff.signoffType)); + const missing: string[] = []; + + if (checklistDone < checklistTotal) { + missing.push(`${checklistTotal - checklistDone} checklist item(s)`); + } + if (!signoffTypes.has("Customer")) { + missing.push("Customer sign-off"); + } + if (!signoffTypes.has("Vendor")) { + missing.push("Vendor sign-off"); + } + + return { + missing, + canVerify: missing.length === 0 && (checklistTotal > 0 || dispatch.signoffs.length >= 2), + }; +} + export function useDispatchDetailController( dispatchId: string | number, workOrderId: string | number, @@ -62,35 +88,7 @@ export function useDispatchDetailController( 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 verifyState = useMemo(() => buildVerifyState(dispatch), [dispatch]); const beginEditing = () => { if (dispatch) {