Add dispatch detail modal with vendor communication thread

- 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
This commit is contained in:
Adam Moussa 2026-04-17 14:01:13 -04:00
parent 7428a6d5e3
commit 2a23febcec
3 changed files with 400 additions and 4 deletions

View file

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

View file

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

View file

@ -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(
<div className="woDispatchOverlay"><div className="woDispatchModal"><div className="woDispatchLoading">Loading...</div></div></div>,
document.body
);
}
if (!dispatch) return null;
return ReactDOM.createPortal(
<div className="woDispatchOverlay" onClick={onClose}>
<div className="dispatchDetailModal" onClick={(e) => e.stopPropagation()}>
<div className="woDispatchHeader">
<div>
<h3>{dispatch.dispatchNumber || dispatch.poNumber} · {dispatch.vendorName}</h3>
<span className="dispatchDetailSub">PO: {dispatch.poNumber} · WO: {dispatch.woNumber} · {dispatch.vendorEmail}</span>
</div>
<button className="woDispatchClose" onClick={onClose}>&times;</button>
</div>
<div className="dispatchDetailBody">
<div className="dispatchDetailSection">
<div className="dispatchDetailGrid">
{editing ? (
<>
<div className="dispatchDetailField">
<label>Status</label>
<select className="form-control" value={form.status} onChange={(e) => setForm(f => ({ ...f, status: e.target.value }))}>
{DISPATCH_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="dispatchDetailField">
<label>NTE Amount</label>
<input className="form-control" type="number" value={form.nteAmount} onChange={(e) => setForm(f => ({ ...f, nteAmount: e.target.value }))} step="0.01" />
</div>
<div className="dispatchDetailField">
<label>Scheduled Date</label>
<input className="form-control" type="date" value={form.scheduledDate} onChange={(e) => setForm(f => ({ ...f, scheduledDate: e.target.value }))} />
</div>
<div className="dispatchDetailField">
<label>Completed Date</label>
<input className="form-control" type="date" value={form.completedDate} onChange={(e) => setForm(f => ({ ...f, completedDate: e.target.value }))} />
</div>
<div className="dispatchDetailField dispatchDetailFieldFull">
<label>Description</label>
<textarea className="form-control" value={form.description} onChange={(e) => setForm(f => ({ ...f, description: e.target.value }))} rows="3" style={{ resize: "none" }} />
</div>
<div className="dispatchDetailActions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>{saving ? "Saving..." : "Save"}</button>
<button className="btn btn-default" onClick={() => setEditing(false)}>Cancel</button>
</div>
</>
) : (
<>
<div className="dispatchDetailField">
<label>Status</label>
<span className={`woDispatchStatus ${(dispatch.status || "").toLowerCase()}`}>{dispatch.status}</span>
</div>
<div className="dispatchDetailField">
<label>NTE</label>
<span>{dispatch.nteAmount != null ? `$${parseFloat(dispatch.nteAmount).toFixed(2)}` : "—"}</span>
</div>
<div className="dispatchDetailField">
<label>Scheduled</label>
<span>{dispatch.scheduledDate ? dispatch.scheduledDate.split("T")[0] : "—"}</span>
</div>
<div className="dispatchDetailField">
<label>Completed</label>
<span>{dispatch.completedDate ? dispatch.completedDate.split("T")[0] : "—"}</span>
</div>
<div className="dispatchDetailField">
<label>Dispatched</label>
<span>{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}</span>
</div>
<div className="dispatchDetailField">
<label>Vendor Email</label>
<span>{dispatch.vendorEmail}</span>
</div>
{dispatch.description && (
<div className="dispatchDetailField dispatchDetailFieldFull">
<label>Description</label>
<span>{dispatch.description}</span>
</div>
)}
<div className="dispatchDetailActions">
<button className="btn btn-primary" onClick={() => setEditing(true)}>Edit</button>
</div>
</>
)}
</div>
</div>
<div className="dispatchDetailSection">
<h4>Vendor Communication</h4>
<div className="dispatchThreadContainer">
<div className="dispatchThreadScroll">
{(!dispatch.comments || dispatch.comments.length === 0) ? (
<p className="woNoComments">No messages yet.</p>
) : (
<div className="woTimeline">
{dispatch.comments.map((c) => (
<div key={c.id} className={`woTimelineItem ${c.commenter ? "woTimelineInbound" : ""}`}>
<div className="woTimelineHeader">
<span className="woTimelineAuthor">{c.userName || c.commenter || "System"}</span>
<span className="woTimelineDate">{c.createdDate ? formatDateTime(c.createdDate) : ""}</span>
</div>
<div className="woTimelineBody">{c.commenttext}</div>
</div>
))}
<div ref={bottomRef} />
</div>
)}
</div>
<form className="dispatchThreadInput" onSubmit={handleComment}>
<textarea
className="woCommentTextarea"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleComment(e); } }}
placeholder="Message vendor..."
rows={2}
disabled={sendingComment}
/>
<div className="dispatchThreadControls">
<label className="dispatchEmailToggle">
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
Send email
</label>
<button className="btn btn-primary woCommentSend" type="submit" disabled={sendingComment || !commentText.trim()}>
{sendingComment ? "Sending..." : "Send"}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
function DispatchModal({ wo, onClose, onDispatched }) {
const [vendors, setVendors] = useState([]);
const [selectedId, setSelectedId] = useState(null);
@ -390,6 +606,7 @@ export default function WorkOrderViewPage() {
const [activeTab, setActiveTab] = useState("customer");
const [statusUpdating, setStatusUpdating] = useState(false);
const [dispatchOpen, setDispatchOpen] = useState(false);
const [dispatchDetailId, setDispatchDetailId] = useState(null);
const handleStatusChange = async (newStatus) => {
if (newStatus === wo?.status) return;
@ -609,12 +826,12 @@ export default function WorkOrderViewPage() {
{wo.dispatches?.length ? (
<table className="woDispatchTable">
<thead>
<tr><th>PO #</th><th>Vendor</th><th>NTE</th><th>Status</th><th>Date</th></tr>
<tr><th>Dispatch #</th><th>Vendor</th><th>NTE</th><th>Status</th><th>Date</th></tr>
</thead>
<tbody>
{wo.dispatches.map(d => (
<tr key={d.id}>
<td>{d.poNumber}</td>
<tr key={d.id} className="dispatchClickRow" onClick={() => setDispatchDetailId(d.id)}>
<td>{d.dispatchNumber || d.poNumber}</td>
<td>{d.vendorName}</td>
<td>{d.nteAmount != null ? `$${parseFloat(d.nteAmount).toFixed(2)}` : "—"}</td>
<td><span className={`woDispatchStatus ${(d.status || "").toLowerCase()}`}>{d.status}</span></td>
@ -673,6 +890,17 @@ export default function WorkOrderViewPage() {
}}
/>
)}
{dispatchDetailId && (
<DispatchDetailModal
dispatchId={dispatchDetailId}
onClose={() => setDispatchDetailId(null)}
onUpdated={async () => {
const data = await getWorkOrderById(id);
setWo(data);
}}
/>
)}
</div>
);
}