From 2a23febcec905e67fd6ef97ec8e739ef14bdb4a0 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:01:13 -0400 Subject: [PATCH] Add dispatch detail modal with vendor communication thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Click dispatch row to open detail modal - View/edit dispatch status, NTE, scheduled/completed dates, description - Vendor Communication thread with message input - Send email toggle — messages optionally email the vendor - Modal sized for long email threads with scrollable communication area - Dispatch table shows dispatch number, clickable rows --- src/pages/workorders/api.js | 17 ++ .../workorders/view/WorkOrderViewPage.css | 151 +++++++++++ .../workorders/view/WorkOrderViewPage.js | 236 +++++++++++++++++- 3 files changed, 400 insertions(+), 4 deletions(-) diff --git a/src/pages/workorders/api.js b/src/pages/workorders/api.js index 9f4a157b..3d8e22a2 100644 --- a/src/pages/workorders/api.js +++ b/src/pages/workorders/api.js @@ -121,6 +121,23 @@ export const fetchVendorsDropdown = async (trade, siteZip) => { return (await apiGet(`${API_URL}/Vendor/Dropdown${qs ? `?${qs}` : ""}`)) || []; }; +export const getDispatchById = async (id) => { + const { get: apiGet } = await import("../../apiUtil"); + return apiGet(`${API_URL}/WorkOrder/GetDispatch/${id}`); +}; + +export const updateDispatch = async (payload) => { + return post(`${API_URL}/WorkOrder/UpdateDispatch`, payload); +}; + +export const addDispatchComment = async (dispatchId, text, sendEmail = true) => { + return post(`${API_URL}/WorkOrder/AddDispatchComment`, { + DispatchId: dispatchId, + Text: text, + SendEmail: sendEmail, + }); +}; + export const dispatchToVendor = async (workOrderId, payload) => { return post(`${API_URL}/WorkOrder/DispatchToVendor`, { WorkOrderId: workOrderId, diff --git a/src/pages/workorders/view/WorkOrderViewPage.css b/src/pages/workorders/view/WorkOrderViewPage.css index 590a38bd..8337864c 100644 --- a/src/pages/workorders/view/WorkOrderViewPage.css +++ b/src/pages/workorders/view/WorkOrderViewPage.css @@ -630,6 +630,157 @@ .woDispatchStatus.completed { background: #28a745; } .woDispatchStatus.cancelled { background: #dc3545; } +.dispatchClickRow { + cursor: pointer; +} + +.dispatchClickRow:hover { + background: #f0f7ff; +} + +.dispatchDetailModal { + background: #fff; + border-radius: 8px; + width: 800px; + height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 12px 40px rgba(0,0,0,0.2); +} + +.dispatchDetailModal .woDispatchHeader { + padding: 14px 20px; +} + +.dispatchDetailModal .woDispatchHeader h3 { + font-size: 15px; +} + +.dispatchDetailSub { + font-size: 12px; + color: #999; + display: block; + margin-top: 2px; +} + +.dispatchDetailBody { + overflow-y: auto; + flex: 1; + padding: 0; + display: flex; + flex-direction: column; +} + +.dispatchDetailSection { + padding: 16px 20px; + border-bottom: 1px solid #eee; +} + +.dispatchDetailSection:last-child { + flex: 1; + display: flex; + flex-direction: column; + border-bottom: none; + overflow: hidden; +} + +.dispatchDetailSection h4 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + color: #666; +} + +.dispatchDetailGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.dispatchDetailField { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dispatchDetailField label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: #999; + margin-bottom: 0; +} + +.dispatchDetailField span { + font-size: 13px; + color: #333; +} + +.dispatchDetailField .woDispatchStatus { + font-size: 11px; + width: fit-content; +} + +.dispatchDetailFieldFull { + grid-column: 1 / -1; +} + +.dispatchDetailActions { + grid-column: 1 / -1; + display: flex; + gap: 8px; + justify-content: flex-end; + padding-top: 4px; +} + +.dispatchThreadContainer { + display: flex; + flex-direction: column; + flex: 1; + min-height: 200px; + border: 1px solid #e5e5e5; + border-radius: 6px; + overflow: hidden; +} + +.dispatchThreadScroll { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.woTimelineInbound { + border-left-color: #28a745; +} + +.dispatchThreadInput { + border-top: 1px solid #e5e5e5; + padding: 10px 12px; + background: #f8f9fa; +} + +.dispatchThreadInput .woCommentTextarea { + width: 100%; + box-sizing: border-box; +} + +.dispatchThreadControls { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 6px; +} + +.dispatchEmailToggle { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #666; + cursor: pointer; +} + .loading { padding: 40px; text-align: center; diff --git a/src/pages/workorders/view/WorkOrderViewPage.js b/src/pages/workorders/view/WorkOrderViewPage.js index e8fbe9e4..6aff0b80 100644 --- a/src/pages/workorders/view/WorkOrderViewPage.js +++ b/src/pages/workorders/view/WorkOrderViewPage.js @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import ReactDOM from "react-dom"; import { useParams, useNavigate } from "react-router"; import { toast } from "react-toastify"; -import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor } from "../api"; +import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment } from "../api"; import "./WorkOrderViewPage.css"; function AssignDropdown({ value, onAssign }) { @@ -160,6 +160,222 @@ function AuditTimeline({ entries }) { ); } +const DISPATCH_STATUSES = ["Sent", "Acknowledged", "In Progress", "Completed", "Cancelled"]; + +function DispatchDetailModal({ dispatchId, onClose, onUpdated }) { + const [dispatch, setDispatch] = useState(null); + const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [form, setForm] = useState({}); + const [saving, setSaving] = useState(false); + const [commentText, setCommentText] = useState(""); + const [sendEmail, setSendEmail] = useState(true); + const [sendingComment, setSendingComment] = useState(false); + const bottomRef = useRef(null); + + const loadDispatch = async () => { + try { + const data = await getDispatchById(dispatchId); + setDispatch(data); + setForm({ + status: data.status || "", + nteAmount: data.nteAmount != null ? String(data.nteAmount) : "", + scheduledDate: data.scheduledDate ? data.scheduledDate.split("T")[0] : "", + completedDate: data.completedDate ? data.completedDate.split("T")[0] : "", + description: data.description || "", + }); + } catch { + toast.error("Failed to load dispatch"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { loadDispatch(); }, [dispatchId]); + useEffect(() => { bottomRef.current?.scrollIntoView(); }, [dispatch?.comments?.length]); + + const handleSave = async () => { + setSaving(true); + try { + await updateDispatch({ + Id: dispatchId, + Status: form.status, + NTEAmount: form.nteAmount ? parseFloat(form.nteAmount) : null, + ScheduledDate: form.scheduledDate || null, + CompletedDate: form.completedDate || null, + Description: form.description, + }); + toast.success("Dispatch updated"); + setEditing(false); + loadDispatch(); + onUpdated(); + } catch { + toast.error("Failed to update dispatch"); + } finally { + setSaving(false); + } + }; + + const handleComment = async (e) => { + e.preventDefault(); + if (!commentText.trim()) return; + setSendingComment(true); + try { + const newComment = await addDispatchComment(dispatchId, commentText.trim(), sendEmail); + setCommentText(""); + setDispatch((prev) => ({ + ...prev, + comments: [...(prev.comments || []), newComment], + })); + } catch { + toast.error("Failed to send message"); + } finally { + setSendingComment(false); + } + }; + + if (loading) { + return ReactDOM.createPortal( +
Loading...
, + document.body + ); + } + + if (!dispatch) return null; + + return ReactDOM.createPortal( +
+
e.stopPropagation()}> +
+
+

{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}

+ PO: {dispatch.poNumber} · WO: {dispatch.woNumber} · {dispatch.vendorEmail} +
+ +
+ +
+
+
+ {editing ? ( + <> +
+ + +
+
+ + setForm(f => ({ ...f, nteAmount: e.target.value }))} step="0.01" /> +
+
+ + setForm(f => ({ ...f, scheduledDate: e.target.value }))} /> +
+
+ + setForm(f => ({ ...f, completedDate: e.target.value }))} /> +
+
+ +