mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
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:
parent
7428a6d5e3
commit
2a23febcec
3 changed files with 400 additions and 4 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>×</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue