From 0167a1aa75995a22230173a271caa6908f65964c Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Fri, 17 Apr 2026 11:37:49 -0400
Subject: [PATCH] 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
---
src/App.js | 9 +
src/components/Sidebar.js | 2 +
src/pages/vendors/form/VendorFormPage.css | 35 +++
src/pages/vendors/form/VendorFormPage.js | 232 ++++++++++++++++++
src/pages/vendors/list/List.js | 82 +++++++
src/pages/vendors/list/Table.js | 17 ++
src/pages/vendors/list/api.js | 25 ++
src/pages/vendors/list/styles.css | 63 +++++
src/pages/workorders/api.js | 15 ++
.../workorders/view/WorkOrderViewPage.css | 166 +++++++++++++
.../workorders/view/WorkOrderViewPage.js | 123 +++++++++-
src/services/api.js | 8 +
12 files changed, 776 insertions(+), 1 deletion(-)
create mode 100644 src/pages/vendors/form/VendorFormPage.css
create mode 100644 src/pages/vendors/form/VendorFormPage.js
create mode 100644 src/pages/vendors/list/List.js
create mode 100644 src/pages/vendors/list/Table.js
create mode 100644 src/pages/vendors/list/api.js
create mode 100644 src/pages/vendors/list/styles.css
diff --git a/src/App.js b/src/App.js
index a143e4f8..65961e6e 100644
--- a/src/App.js
+++ b/src/App.js
@@ -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() {
} />
} />
+ {/* VENDORS */}
+ } />
+ } />
+ } />
+
{/* CALENDAR */}
} />
} />
diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js
index c8970b5c..1f0a7c73 100644
--- a/src/components/Sidebar.js
+++ b/src/components/Sidebar.js
@@ -14,6 +14,7 @@ import {
FaCubes,
FaMapMarkerAlt,
FaCog,
+ FaTruck,
} from "react-icons/fa";
const navItems = [
@@ -25,6 +26,7 @@ const navItems = [
{ label: "Contacts", icon: , path: "/contacts" },
{ label: "Reports", icon: , path: "/reports" },
{ label: "Work Orders", icon: , path: "/workorders" },
+ { label: "Vendors", icon: , path: "/vendors" },
{ label: "Follow Ups", icon: , path: "/followups" },
{ label: "PM Schedules", icon: , path: "/pmschedules" },
{ label: "Assets", icon: , path: "/assets" },
diff --git a/src/pages/vendors/form/VendorFormPage.css b/src/pages/vendors/form/VendorFormPage.css
new file mode 100644
index 00000000..5bb144cb
--- /dev/null
+++ b/src/pages/vendors/form/VendorFormPage.css
@@ -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;
+}
diff --git a/src/pages/vendors/form/VendorFormPage.js b/src/pages/vendors/form/VendorFormPage.js
new file mode 100644
index 00000000..a805f23c
--- /dev/null
+++ b/src/pages/vendors/form/VendorFormPage.js
@@ -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 (
+
+ );
+}
diff --git a/src/pages/vendors/list/List.js b/src/pages/vendors/list/List.js
new file mode 100644
index 00000000..7f05aa6a
--- /dev/null
+++ b/src/pages/vendors/list/List.js
@@ -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 (
+
+
+
+
Vendors
+ {totalCount} total
+
+
+ { setSearch(e.target.value); setPage(1); }}
+ placeholder="Search vendors..."
+ />
+
+
+
+
+ navigate(`/vendors/edit/${item.id}`)}
+ onDelete={handleDeleteClick}
+ page={page}
+ setPage={setPage}
+ pageSize={pageSize}
+ setPageSize={setPageSize}
+ totalCount={totalCount}
+ totalPages={totalPages}
+ />
+
+ 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}
+ />
+
+ );
+}
diff --git a/src/pages/vendors/list/Table.js b/src/pages/vendors/list/Table.js
new file mode 100644
index 00000000..a11f885c
--- /dev/null
+++ b/src/pages/vendors/list/Table.js
@@ -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) => { e.preventDefault(); onEdit && onEdit(row); }}>{val} },
+ { 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 (
+
+ );
+}
diff --git a/src/pages/vendors/list/api.js b/src/pages/vendors/list/api.js
new file mode 100644
index 00000000..da6104aa
--- /dev/null
+++ b/src/pages/vendors/list/api.js
@@ -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}`);
diff --git a/src/pages/vendors/list/styles.css b/src/pages/vendors/list/styles.css
new file mode 100644
index 00000000..093788a1
--- /dev/null
+++ b/src/pages/vendors/list/styles.css
@@ -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;
+}
diff --git a/src/pages/workorders/api.js b/src/pages/workorders/api.js
index 459d4869..858ed5da 100644
--- a/src/pages/workorders/api.js
+++ b/src/pages/workorders/api.js
@@ -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)}`);
};
diff --git a/src/pages/workorders/view/WorkOrderViewPage.css b/src/pages/workorders/view/WorkOrderViewPage.css
index f23a2dfc..addb6fe5 100644
--- a/src/pages/workorders/view/WorkOrderViewPage.css
+++ b/src/pages/workorders/view/WorkOrderViewPage.css
@@ -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;
diff --git a/src/pages/workorders/view/WorkOrderViewPage.js b/src/pages/workorders/view/WorkOrderViewPage.js
index e71c36cc..cb14aae4 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 } 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(
+
+
e.stopPropagation()}>
+
+
Dispatch Work Order {wo.internalWONumber}
+
+
+
+
+
+ {!loaded ?
Loading vendors...
: vendors.length === 0 ?
No vendors found
: (
+
+ {vendors.map((v) => (
+
+ ))}
+
+ )}
+
+
+
+ setNteAmount(e.target.value)} placeholder="0.00" step="0.01" min="0" />
+
+
+
+
+
+
+
+
+
+
+
,
+ 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() {
+
@@ -482,6 +569,29 @@ export default function WorkOrderViewPage() {
+
+ {wo.dispatches?.length ? (
+
+
+ | PO # | Vendor | NTE | Status | Date |
+
+
+ {wo.dispatches.map(d => (
+
+ | {d.poNumber} |
+ {d.vendorName} |
+ {d.nteAmount != null ? `$${parseFloat(d.nteAmount).toFixed(2)}` : "—"} |
+ {d.status} |
+ {d.dispatchedAt ? formatDateTime(d.dispatchedAt) : "—"} |
+
+ ))}
+
+
+ ) : (
+ No dispatches yet.
+ )}
+
+
{wo.description || "No description provided."}
@@ -516,6 +626,17 @@ export default function WorkOrderViewPage() {
+
+ {dispatchOpen && (
+ setDispatchOpen(false)}
+ onDispatched={async () => {
+ const data = await getWorkOrderById(id);
+ setWo(data);
+ }}
+ />
+ )}
);
}
diff --git a/src/services/api.js b/src/services/api.js
index b65ce906..60f8e764 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -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;