Add vendor pages and dispatch UI

- Vendor CRUD: list page with search, create/edit form with trade specialty chips
- Vendor routes, sidebar nav item with truck icon
- vendorApi in services/api.js
- Dispatch button on WO view page opens DispatchModal
- DispatchModal: vendor multi-select filtered by trade, NTE field, description
- Dispatches CollapsibleSection with PO table on WO view page
- fetchVendorsDropdown and dispatchToVendor API functions
- dispatches included in getWorkOrderById response mapping
This commit is contained in:
Adam Moussa 2026-04-17 11:37:49 -04:00
parent feacb72337
commit 0167a1aa75
12 changed files with 776 additions and 1 deletions

View file

@ -46,6 +46,10 @@ import AssetFormPage from "./pages/assets/form/AssetFormPage";
import CalendarPage from "./pages/calendar/list/CalendarPage";
import EventFormPage from "./pages/calendar/form/EventFormPage";
// VENDORS
import VendorsList from "./pages/vendors/list/List";
import VendorFormPage from "./pages/vendors/form/VendorFormPage";
// SETTINGS
import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage";
@ -120,6 +124,11 @@ export default function App() {
<Route path="/assets/new" element={<AssetFormPage />} />
<Route path="/assets/edit/:id" element={<AssetFormPage />} />
{/* VENDORS */}
<Route path="/vendors" element={<VendorsList />} />
<Route path="/vendors/new" element={<VendorFormPage />} />
<Route path="/vendors/edit/:id" element={<VendorFormPage />} />
{/* CALENDAR */}
<Route path="/calendar" element={<CalendarPage />} />
<Route path="/calendar/new" element={<EventFormPage />} />

View file

@ -14,6 +14,7 @@ import {
FaCubes,
FaMapMarkerAlt,
FaCog,
FaTruck,
} from "react-icons/fa";
const navItems = [
@ -25,6 +26,7 @@ const navItems = [
{ label: "Contacts", icon: <FaAddressBook />, path: "/contacts" },
{ label: "Reports", icon: <FaChartBar />, path: "/reports" },
{ label: "Work Orders", icon: <FaTools />, path: "/workorders" },
{ label: "Vendors", icon: <FaTruck />, path: "/vendors" },
{ label: "Follow Ups", icon: <FaBell />, path: "/followups" },
{ label: "PM Schedules", icon: <FaClipboardList />, path: "/pmschedules" },
{ label: "Assets", icon: <FaCubes />, path: "/assets" },

View file

@ -0,0 +1,35 @@
.vendorTradeGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 4px 0;
}
.vendorTradeChip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
user-select: none;
}
.vendorTradeChip:hover {
border-color: #007bff;
}
.vendorTradeChip input:checked + span {
color: #007bff;
font-weight: 600;
}
.vendorActiveLabel {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}

232
src/pages/vendors/form/VendorFormPage.js vendored Normal file
View file

@ -0,0 +1,232 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { vendorApi } from "../../../services/api";
import { fetchDropdownOptions } from "../../workorders/api";
import "../../../styles/FormPage.css";
import "./VendorFormPage.css";
export default function VendorFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [formData, setFormData] = useState({
companyName: "",
contactName: "",
email: "",
phone: "",
address: "",
city: "",
state: "",
zip: "",
tradeSpecialties: "",
isActive: true,
});
const [loading, setLoading] = useState(false);
const [trades, setTrades] = useState([]);
const [selectedTrades, setSelectedTrades] = useState([]);
useEffect(() => {
fetchDropdownOptions("Trade").then(setTrades).catch(() => {});
}, []);
useEffect(() => {
if (isEdit && id) {
const load = async () => {
try {
const vendor = await vendorApi.getById(id);
if (vendor) {
setFormData(prev => {
const merged = { ...prev };
for (const key of Object.keys(merged)) {
if (vendor[key] != null) merged[key] = vendor[key];
}
return merged;
});
if (vendor.tradeSpecialties) {
setSelectedTrades(vendor.tradeSpecialties.split(",").map(t => t.trim()).filter(Boolean));
}
}
} catch {
toast.error("Failed to load vendor");
}
};
load();
}
}, [id, isEdit]);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
};
const handleTradeToggle = (tradeValue) => {
setSelectedTrades(prev => {
const updated = prev.includes(tradeValue)
? prev.filter(t => t !== tradeValue)
: [...prev, tradeValue];
setFormData(f => ({ ...f, tradeSpecialties: updated.join(", ") }));
return updated;
});
};
const handleSave = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (isEdit) {
await vendorApi.update(id, formData);
toast.success("Vendor updated");
} else {
await vendorApi.create(formData);
toast.success("Vendor created");
}
navigate("/vendors");
} catch {
toast.error("Failed to save vendor");
} finally {
setLoading(false);
}
};
return (
<div className="formPage">
<div className="panel panel-default panel-connected">
<div className="panel-body">
<ol className="breadcrumb breadcrumb-alt">
<li>
<a href="#" onClick={(e) => { e.preventDefault(); navigate("/vendors"); }}>Vendors</a>
</li>
<li className="active">{isEdit ? "Edit" : "Create"}</li>
</ol>
<div className="container">
<form onSubmit={handleSave}>
<div className="form-group">
<div className="header">
<h3 className="heading thin">
<span className="fa-stack" style={{ fontSize: "0.9em" }}>
<i className="fa fa-square fa-stack-2x text-primary"></i>
<i className={`fa fa-${isEdit ? "edit" : "plus"} fa-stack-1x fa-inverse`}></i>
</span>
<span>{isEdit ? "Edit Vendor" : "Create a Vendor"}</span>
</h3>
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>General Details</strong></h5>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="companyName">Company Name</label>
<input className="form-control" type="text" id="companyName" name="companyName" value={formData.companyName} onChange={handleChange} required />
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="contactName">Contact Name</label>
<input className="form-control" type="text" id="contactName" name="contactName" value={formData.contactName} onChange={handleChange} />
</div>
</div>
</div>
<div className="row">
<div className="col-sm-6">
<div className="form-group">
<label className="required-field" htmlFor="email">Email</label>
<input className="form-control" type="email" id="email" name="email" value={formData.email} onChange={handleChange} required />
</div>
</div>
<div className="col-sm-6">
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input className="form-control" type="tel" id="phone" name="phone" value={formData.phone} onChange={handleChange} />
</div>
</div>
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>Address</strong></h5>
</div>
<div className="row">
<div className="col-sm-12">
<div className="form-group">
<label htmlFor="address">Address</label>
<input className="form-control" type="text" id="address" name="address" value={formData.address} onChange={handleChange} />
</div>
</div>
</div>
<div className="row">
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="city">City</label>
<input className="form-control" type="text" id="city" name="city" value={formData.city} onChange={handleChange} />
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="state">State</label>
<input className="form-control" type="text" id="state" name="state" value={formData.state} onChange={handleChange} placeholder="e.g., IL" />
</div>
</div>
<div className="col-sm-4">
<div className="form-group">
<label htmlFor="zip">Zip</label>
<input className="form-control" type="text" id="zip" name="zip" value={formData.zip} onChange={handleChange} />
</div>
</div>
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>Trade Specialties</strong></h5>
</div>
<div className="vendorTradeGrid">
{trades.map((t) => (
<label key={t.id} className="vendorTradeChip">
<input
type="checkbox"
checked={selectedTrades.includes(t.value)}
onChange={() => handleTradeToggle(t.value)}
/>
<span>{t.value}</span>
</label>
))}
</div>
</div>
<div className="form-group">
<div className="header">
<h5 className="heading text-uppercase"><strong>Status</strong></h5>
</div>
<label className="vendorActiveLabel">
<input type="checkbox" name="isActive" checked={formData.isActive} onChange={handleChange} />
<span>Active</span>
</label>
</div>
<div className="form-actions">
<button type="button" className="btn btn-default" onClick={() => navigate("/vendors")} disabled={loading}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "Saving..." : isEdit ? "Update" : "Create"}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
);
}

82
src/pages/vendors/list/List.js vendored Normal file
View file

@ -0,0 +1,82 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import usePaginatedList from "../../../hooks/usePaginatedList";
import { fetchVendorsPaged, deleteVendor } from "./api";
import Table from "./Table";
import DeleteModal from "../../../components/DeleteModal";
import "./styles.css";
export default function VendorsList() {
const navigate = useNavigate();
const {
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err, reload
} = usePaginatedList(fetchVendorsPaged);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, item: null, loading: false });
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const handleDeleteClick = (item) => {
setDeleteModal({ isOpen: true, item, loading: false });
};
const handleDeleteConfirm = async () => {
setDeleteModal((prev) => ({ ...prev, loading: true }));
try {
await deleteVendor(deleteModal.item.id);
toast.success("Vendor deleted");
setDeleteModal({ isOpen: false, item: null, loading: false });
reload();
} catch {
toast.error("Failed to delete vendor");
setDeleteModal((prev) => ({ ...prev, loading: false }));
}
};
return (
<section className="listPage">
<div className="vendorListToolbar">
<div className="vendorListToolbarLeft">
<h2 className="vendorListTitle">Vendors</h2>
<span className="vendorListCount">{totalCount} total</span>
</div>
<div className="vendorListToolbarRight">
<input
className="vendorSearchInput"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Search vendors..."
/>
<button className="vendorNewBtn" onClick={() => navigate("/vendors/new")}>
+ New Vendor
</button>
</div>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onEdit={(item) => navigate(`/vendors/edit/${item.id}`)}
onDelete={handleDeleteClick}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
totalPages={totalPages}
/>
<DeleteModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, item: null, loading: false })}
onConfirm={handleDeleteConfirm}
title="Delete Vendor"
message={`Are you sure you want to delete "${deleteModal.item?.companyName}"?`}
loading={deleteModal.loading}
/>
</section>
);
}

17
src/pages/vendors/list/Table.js vendored Normal file
View file

@ -0,0 +1,17 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
export default function Table({ loading, error, rows, onEdit, onDelete, page, setPage, pageSize, setPageSize, totalCount }) {
const columns = [
{ key: "companyName", label: "Company", render: (val, row) => <strong><a href="#" onClick={(e) => { e.preventDefault(); onEdit && onEdit(row); }}>{val}</a></strong> },
{ key: "contactName", label: "Contact" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Phone" },
{ key: "tradeSpecialties", label: "Trades" },
{ key: "isActive", label: "Active", render: (val) => val ? "Yes" : "No" },
];
return (
<SharedTable rows={rows} columns={columns} onEdit={onEdit} onDelete={onDelete} page={page} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} totalCount={totalCount} loading={loading} error={error} />
);
}

25
src/pages/vendors/list/api.js vendored Normal file
View file

@ -0,0 +1,25 @@
import { get } from "../../../apiUtil";
import { API_URL } from "../../../constants";
export const fetchVendorsPaged = async ({ search = "", page = 1, pageSize = 12 }) => {
const res = await get(`${API_URL}/Vendor/GetVendorList?search=${encodeURIComponent(search)}&page=${page}&pageSize=${pageSize}`);
const rows = (res?.data || res?.Data || []).map(x => ({
id: x.id ?? x.Id,
companyName: x.companyName ?? x.CompanyName ?? "",
contactName: x.contactName ?? x.ContactName ?? "",
email: x.email ?? x.Email ?? "",
phone: x.phone ?? x.Phone ?? "",
tradeSpecialties: x.tradeSpecialties ?? x.TradeSpecialties ?? "",
isActive: x.isActive ?? x.IsActive ?? true,
}));
return {
rows,
page: res?.pageNumber ?? 1,
pageSize: res?.pageSize ?? pageSize,
totalCount: res?.totalCount ?? rows.length,
totalPages: res?.totalPages ?? 1,
};
};
export const deleteVendor = (id) =>
get(`${API_URL}/Vendor/DeleteVendor?id=${id}`);

63
src/pages/vendors/list/styles.css vendored Normal file
View file

@ -0,0 +1,63 @@
.vendorListToolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e5e5e5;
}
.vendorListToolbarLeft {
display: flex;
align-items: baseline;
gap: 12px;
}
.vendorListTitle {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
.vendorListCount {
font-size: 13px;
color: #999;
}
.vendorListToolbarRight {
display: flex;
align-items: center;
gap: 12px;
}
.vendorSearchInput {
width: 260px;
height: 38px;
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.vendorSearchInput:focus {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
}
.vendorNewBtn {
padding: 8px 16px;
background: #007bff;
color: #fff;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.vendorNewBtn:hover {
background: #0069d9;
}

View file

@ -41,6 +41,7 @@ export const getWorkOrderById = async (id) => {
source: wo.source ?? "",
comments: wo.comments ?? [],
auditLog: wo.auditLog ?? [],
dispatches: wo.dispatches ?? [],
};
} catch (error) {
console.error("Error fetching work order:", error);
@ -110,6 +111,20 @@ export const fetchDropdownOptions = async (category, parent) => {
return (await apiGet(url)) || [];
};
export const fetchVendorsDropdown = async (trade) => {
const { get: apiGet } = await import("../../apiUtil");
let url = `${API_URL}/Vendor/Dropdown`;
if (trade) url += `?trade=${encodeURIComponent(trade)}`;
return (await apiGet(url)) || [];
};
export const dispatchToVendor = async (workOrderId, payload) => {
return post(`${API_URL}/WorkOrder/DispatchToVendor`, {
WorkOrderId: workOrderId,
...payload,
});
};
export const changeWorkOrderStatus = async (id, status) => {
return post(`${API_URL}/WorkOrder/ChangeStatus?id=${id}&status=${encodeURIComponent(status)}`);
};

View file

@ -368,6 +368,172 @@
font-size: 13px;
}
.btn-success {
background: #28a745;
color: #fff;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-success:hover {
background: #218838;
}
.woDispatchOverlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.woDispatchModal {
background: #fff;
border-radius: 8px;
width: 560px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0,0,0,0.2);
}
.woDispatchHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #eee;
}
.woDispatchHeader h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.woDispatchClose {
background: none;
border: none;
font-size: 22px;
color: #999;
cursor: pointer;
}
.woDispatchBody {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.woDispatchField {
margin-bottom: 16px;
}
.woDispatchField label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: #333;
}
.woDispatchVendorList {
max-height: 200px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
}
.woDispatchVendorItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.woDispatchVendorItem:last-child {
border-bottom: none;
}
.woDispatchVendorItem:hover {
background: #f8f9fa;
}
.woDispatchVendorItem.selected {
background: #e6f0ff;
}
.woDispatchVendorName {
font-size: 13px;
font-weight: 500;
display: block;
}
.woDispatchVendorTrades {
font-size: 11px;
color: #999;
display: block;
}
.woDispatchLoading {
padding: 20px;
text-align: center;
color: #999;
font-size: 13px;
}
.woDispatchFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid #eee;
}
.woDispatchTable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.woDispatchTable th {
text-align: left;
padding: 8px 12px;
font-weight: 600;
color: #666;
border-bottom: 1px solid #ddd;
font-size: 11px;
text-transform: uppercase;
}
.woDispatchTable td {
padding: 8px 12px;
border-bottom: 1px solid #eee;
}
.woDispatchStatus {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
color: #fff;
background: #6c757d;
}
.woDispatchStatus.sent { background: #007bff; }
.woDispatchStatus.acknowledged { background: #ffc107; color: #333; }
.woDispatchStatus.completed { background: #28a745; }
.woDispatchStatus.cancelled { background: #dc3545; }
.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 } from "../api";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor } from "../api";
import "./WorkOrderViewPage.css";
function AssignDropdown({ value, onAssign }) {
@ -160,6 +160,89 @@ function AuditTimeline({ entries }) {
);
}
function DispatchModal({ wo, onClose, onDispatched }) {
const [vendors, setVendors] = useState([]);
const [selectedIds, setSelectedIds] = useState([]);
const [nteAmount, setNteAmount] = useState(wo.vendorNTE || "");
const [description, setDescription] = useState(wo.description || "");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
fetchVendorsDropdown(wo.trade).then((data) => {
setVendors(data);
setLoaded(true);
}).catch(() => setLoaded(true));
}, [wo.trade]);
const toggleVendor = (id) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(v => v !== id) : [...prev, id]);
};
const handleSubmit = async () => {
if (selectedIds.length === 0) { toast.error("Select at least one vendor"); return; }
setSending(true);
try {
await dispatchToVendor(wo.id, {
VendorIds: selectedIds,
NTEAmount: nteAmount ? parseFloat(nteAmount) : null,
Description: description,
});
toast.success(`Dispatched to ${selectedIds.length} vendor(s)`);
onDispatched();
onClose();
} catch {
toast.error("Failed to dispatch");
} finally {
setSending(false);
}
};
return ReactDOM.createPortal(
<div className="woDispatchOverlay" onClick={onClose}>
<div className="woDispatchModal" onClick={(e) => e.stopPropagation()}>
<div className="woDispatchHeader">
<h3>Dispatch Work Order {wo.internalWONumber}</h3>
<button className="woDispatchClose" onClick={onClose}>&times;</button>
</div>
<div className="woDispatchBody">
<div className="woDispatchField">
<label>Select Vendors</label>
{!loaded ? <div className="woDispatchLoading">Loading vendors...</div> : vendors.length === 0 ? <div className="woDispatchLoading">No vendors found</div> : (
<div className="woDispatchVendorList">
{vendors.map((v) => (
<label key={v.id} className={`woDispatchVendorItem ${selectedIds.includes(v.id) ? "selected" : ""}`}>
<input type="checkbox" checked={selectedIds.includes(v.id)} onChange={() => toggleVendor(v.id)} />
<div>
<span className="woDispatchVendorName">{v.companyName}</span>
{v.tradeSpecialties && <span className="woDispatchVendorTrades">{v.tradeSpecialties}</span>}
</div>
</label>
))}
</div>
)}
</div>
<div className="woDispatchField">
<label>NTE Amount</label>
<input type="number" className="form-control" value={nteAmount} onChange={(e) => setNteAmount(e.target.value)} placeholder="0.00" step="0.01" min="0" />
</div>
<div className="woDispatchField">
<label>Description</label>
<textarea className="form-control" value={description} onChange={(e) => setDescription(e.target.value)} rows="4" style={{ resize: "none" }} />
</div>
</div>
<div className="woDispatchFooter">
<button className="btn btn-default" onClick={onClose} disabled={sending}>Cancel</button>
<button className="btn btn-primary" onClick={handleSubmit} disabled={sending || selectedIds.length === 0}>
{sending ? "Dispatching..." : `Dispatch to ${selectedIds.length} Vendor(s)`}
</button>
</div>
</div>
</div>,
document.body
);
}
function CommentInput({ workOrderId, commentType, onCommentAdded }) {
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
@ -270,6 +353,7 @@ export default function WorkOrderViewPage() {
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("customer");
const [statusUpdating, setStatusUpdating] = useState(false);
const [dispatchOpen, setDispatchOpen] = useState(false);
const handleStatusChange = async (newStatus) => {
if (newStatus === wo?.status) return;
@ -370,6 +454,9 @@ export default function WorkOrderViewPage() {
<StatusDropdown status={wo.status} onStatusChange={handleStatusChange} />
</div>
<div className="woViewActions">
<button className="btn btn-success" onClick={() => setDispatchOpen(true)}>
Dispatch
</button>
<button className="btn btn-primary" onClick={() => navigate(`/workorders/edit/${id}`)}>
Edit
</button>
@ -482,6 +569,29 @@ export default function WorkOrderViewPage() {
</div>
</CollapsibleSection>
<CollapsibleSection title={`Dispatches (${(wo.dispatches || []).length})`}>
{wo.dispatches?.length ? (
<table className="woDispatchTable">
<thead>
<tr><th>PO #</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>
<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>
<td>{d.dispatchedAt ? formatDateTime(d.dispatchedAt) : "—"}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="woNoComments">No dispatches yet.</p>
)}
</CollapsibleSection>
<CollapsibleSection title="Description">
<p className="woDescription">{wo.description || "No description provided."}</p>
</CollapsibleSection>
@ -516,6 +626,17 @@ export default function WorkOrderViewPage() {
</div>
</div>
</div>
{dispatchOpen && (
<DispatchModal
wo={wo}
onClose={() => setDispatchOpen(false)}
onDispatched={async () => {
const data = await getWorkOrderById(id);
setWo(data);
}}
/>
)}
</div>
);
}

View file

@ -133,5 +133,13 @@ export const followupApi = {
delete: (id) => api.delete(`/followups/${id}`),
};
export const vendorApi = {
getAll: (params) => api.get('/vendors', params),
getById: (id) => api.get(`/vendors/${id}`),
create: (data) => api.post('/vendors', data),
update: (id, data) => api.put(`/vendors/${id}`, data),
delete: (id) => api.delete(`/vendors/${id}`),
};
export default api;