mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-03 08:03:22 +00:00
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:
parent
feacb72337
commit
0167a1aa75
12 changed files with 776 additions and 1 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
35
src/pages/vendors/form/VendorFormPage.css
vendored
Normal file
35
src/pages/vendors/form/VendorFormPage.css
vendored
Normal 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
232
src/pages/vendors/form/VendorFormPage.js
vendored
Normal 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
82
src/pages/vendors/list/List.js
vendored
Normal 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
17
src/pages/vendors/list/Table.js
vendored
Normal 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
25
src/pages/vendors/list/api.js
vendored
Normal 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
63
src/pages/vendors/list/styles.css
vendored
Normal 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;
|
||||
}
|
||||
|
|
@ -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)}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>×</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue