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 ( +
+
+
+
    +
  1. + { e.preventDefault(); navigate("/vendors"); }}>Vendors +
  2. +
  3. {isEdit ? "Edit" : "Create"}
  4. +
+ +
+
+
+
+

+ + + + + {isEdit ? "Edit Vendor" : "Create a Vendor"} +

+
+
+ +
+
+
General Details
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
Address
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
Trade Specialties
+
+
+ {trades.map((t) => ( + + ))} +
+
+ +
+
+
Status
+
+ +
+ +
+ + +
+
+
+
+
+
+ ); +} 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" /> +
+
+ +