Add NTE uplift UI, vendor PO pages, and portal POs tab

Vendors can now request NTE uplifts from the dispatch detail page and
dispatchers can approve or deny them (with tier-gated buttons) from
the work order view. Admin uplift queue page lists all requests with
status/tier filters. New Vendor POs section lists every PO with a
detail page showing vendor, linked WOs, uplift history, checklist,
signoffs, comments, and scoped audit log with print support. Vendor
portal gets a POs tab alongside Work so vendors can reconcile by PO
number.
This commit is contained in:
Adam Moussa 2026-04-20 13:26:08 -04:00
parent c98a905d4d
commit 2a6fe6f35e
19 changed files with 1988 additions and 6 deletions

View file

@ -50,6 +50,13 @@ import EventFormPage from "./pages/calendar/form/EventFormPage";
import VendorsList from "./pages/vendors/list/List";
import VendorFormPage from "./pages/vendors/form/VendorFormPage";
// UPLIFTS
import UpliftQueuePage from "./pages/uplifts/UpliftQueuePage";
// VENDOR POs
import VendorPoList from "./pages/vendor-pos/list/List";
import VendorPoViewPage from "./pages/vendor-pos/view/VendorPoViewPage";
// SETTINGS
import DropdownOptionsPage from "./pages/settings/DropdownOptionsPage";
import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage";
@ -57,6 +64,7 @@ import TaskTemplatesPage from "./pages/settings/TaskTemplatesPage";
// VENDOR PORTAL (public, token-authenticated, separate tree)
import VendorPortalLayout from "./pages/vendor-portal/VendorPortalLayout";
import VendorPortalDashboard from "./pages/vendor-portal/DashboardPage";
import VendorPortalPos from "./pages/vendor-portal/PosPage";
import VendorPortalDispatchDetail from "./pages/vendor-portal/DispatchDetailPage";
// Protected Route Component
@ -77,6 +85,7 @@ export default function App() {
<Route path="/v/:token" element={<VendorPortalLayout />}>
<Route index element={<Navigate to="dashboard" replace />} />
<Route path="dashboard" element={<VendorPortalDashboard />} />
<Route path="pos" element={<VendorPortalPos />} />
<Route path="dispatch/:id" element={<VendorPortalDispatchDetail />} />
</Route>
@ -147,6 +156,13 @@ export default function App() {
<Route path="/calendar/new" element={<EventFormPage />} />
<Route path="/calendar/edit/:id" element={<EventFormPage />} />
{/* UPLIFTS */}
<Route path="/uplifts" element={<UpliftQueuePage />} />
{/* VENDOR POs */}
<Route path="/vendor-pos" element={<VendorPoList />} />
<Route path="/vendor-pos/:id" element={<VendorPoViewPage />} />
{/* SETTINGS */}
<Route path="/settings/dropdowns" element={<DropdownOptionsPage />} />
<Route path="/settings/task-templates" element={<TaskTemplatesPage />} />

View file

@ -15,6 +15,8 @@ import {
FaMapMarkerAlt,
FaCog,
FaTruck,
FaDollarSign,
FaFileInvoiceDollar,
} from "react-icons/fa";
const navItems = [
@ -27,6 +29,8 @@ const navItems = [
{ label: "Reports", icon: <FaChartBar />, path: "/reports" },
{ label: "Work Orders", icon: <FaTools />, path: "/workorders" },
{ label: "Vendors", icon: <FaTruck />, path: "/vendors" },
{ label: "Vendor POs", icon: <FaFileInvoiceDollar />, path: "/vendor-pos" },
{ label: "Uplifts", icon: <FaDollarSign />, path: "/uplifts" },
{ 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,135 @@
.upliftQueuePage {
padding: 24px;
}
.upliftQueueHeader {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.upliftQueueHeader h2 {
margin: 0;
font-size: 22px;
color: #1f2937;
}
.upliftQueueFilters {
display: flex;
gap: 16px;
align-items: center;
font-size: 14px;
color: #374151;
}
.upliftQueueFilters label {
display: flex;
gap: 6px;
align-items: center;
}
.upliftQueueFilters select {
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
background: #fff;
}
.upliftQueueCount {
color: #6b7280;
font-size: 13px;
}
.upliftQueueLoading,
.upliftQueueEmpty {
padding: 40px;
text-align: center;
color: #6b7280;
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 8px;
}
.upliftQueueTable {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 8px;
overflow: hidden;
font-size: 14px;
}
.upliftQueueTable th,
.upliftQueueTable td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #f1f3f5;
}
.upliftQueueTable th {
background: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 13px;
}
.upliftQueueTable tr:last-child td {
border-bottom: none;
}
.upliftStatusPill {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.upliftStatusPill-pending { background: #fef3c7; color: #92400e; }
.upliftStatusPill-approved { background: #d1fae5; color: #065f46; }
.upliftStatusPill-denied { background: #fee2e2; color: #991b1b; }
.upliftStatusPill-cancelled { background: #e5e7eb; color: #4b5563; }
.upliftTier {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.upliftTier-1 { background: #e0e7ff; color: #3730a3; }
.upliftTier-2 { background: #fef3c7; color: #92400e; }
.upliftActions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.upliftQueuePager {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-top: 16px;
font-size: 14px;
}
.upliftQueuePager button {
padding: 6px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
.upliftQueuePager button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View file

@ -0,0 +1,182 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { upliftApi } from "../../services/api";
import "./UpliftQueuePage.css";
function formatDateTime(value) {
if (!value) return "";
const d = new Date(value);
if (isNaN(d)) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const STATUS_OPTIONS = ["Pending", "Approved", "Denied", "Cancelled"];
export default function UpliftQueuePage() {
const navigate = useNavigate();
const [status, setStatus] = useState("Pending");
const [tier, setTier] = useState("");
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(25);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const params = { status, page, pageSize };
if (tier) params.tier = tier;
const res = await upliftApi.list(params);
const data = res?.data ?? res;
setItems(data?.items || []);
setTotal(data?.total || 0);
} catch (err) {
toast.error(err.message || "Failed to load uplift queue");
} finally {
setLoading(false);
}
}, [status, tier, page, pageSize]);
useEffect(() => { load(); }, [load]);
async function approve(req) {
const note = window.prompt("Optional approval note:", "") || "";
if (!window.confirm(`Approve uplift to $${Number(req.requestedNTE).toFixed(2)}?`)) return;
try {
await upliftApi.approve(req.id, note);
toast.success("Uplift approved — NTE updated");
load();
} catch (err) {
toast.error(err.message || "Failed to approve");
}
}
async function deny(req) {
const note = window.prompt("Denial reason (required):", "");
if (!note || !note.trim()) {
toast.error("A reason is required when denying.");
return;
}
try {
await upliftApi.deny(req.id, note.trim());
toast.success("Uplift denied");
load();
} catch (err) {
toast.error(err.message || "Failed to deny");
}
}
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="upliftQueuePage">
<div className="upliftQueueHeader">
<h2>Uplift Queue</h2>
<div className="upliftQueueFilters">
<label>
Status:
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
{STATUS_OPTIONS.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="">All</option>
</select>
</label>
<label>
Tier:
<select value={tier} onChange={(e) => { setTier(e.target.value); setPage(1); }}>
<option value="">All</option>
<option value="1">Tier 1</option>
<option value="2">Tier 2</option>
</select>
</label>
<span className="upliftQueueCount">{total} total</span>
</div>
</div>
{loading ? (
<div className="upliftQueueLoading">Loading…</div>
) : items.length === 0 ? (
<div className="upliftQueueEmpty">No uplift requests match these filters.</div>
) : (
<table className="upliftQueueTable">
<thead>
<tr>
<th>Dispatch</th>
<th>PO</th>
<th>Vendor</th>
<th>Current NTE</th>
<th>Requested</th>
<th>Delta</th>
<th>Tier</th>
<th>Status</th>
<th>Requested At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.map((r) => (
<tr key={r.id}>
<td>{r.dispatchNumber}</td>
<td>{r.poNumber}</td>
<td>{r.vendorCompanyName}</td>
<td>${Number(r.currentNTE ?? 0).toFixed(2)}</td>
<td>${Number(r.requestedNTE).toFixed(2)}</td>
<td>${Number(r.delta).toFixed(2)}</td>
<td>
<span className={`upliftTier upliftTier-${r.requiredTier}`}>
Tier {r.requiredTier}
</span>
</td>
<td>
<span className={`upliftStatusPill upliftStatusPill-${String(r.status).toLowerCase()}`}>
{r.status}
</span>
</td>
<td>{r.requestedAt ? formatDateTime(r.requestedAt) : ""}</td>
<td>
<div className="upliftActions">
{r.workOrderId && (
<button className="btn btn-default" onClick={() => navigate(`/workorders/${r.workOrderId}`)}>
View
</button>
)}
{r.status === "Pending" && (
<>
<button
className="btn btn-success"
disabled={!r.canDecide}
title={!r.canDecide ? `Requires Tier ${r.requiredTier} role` : ""}
onClick={() => approve(r)}
>
Approve
</button>
<button
className="btn btn-danger"
disabled={!r.canDecide}
title={!r.canDecide ? `Requires Tier ${r.requiredTier} role` : ""}
onClick={() => deny(r)}
>
Deny
</button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{totalPages > 1 && (
<div className="upliftQueuePager">
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>← Prev</button>
<span>Page {page} of {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>Next →</button>
</div>
)}
</section>
);
}

View file

@ -34,9 +34,9 @@ export default function DashboardPage() {
return (
<>
<div className="vp-card">
<h2>Your Dispatches</h2>
<h2>Your Work</h2>
<div className="vp-muted" style={{ marginBottom: 12 }}>
View and manage work orders assigned to your company.
Active dispatches and work orders assigned to your company.
</div>
<div className="vp-actions" style={{ marginTop: 0 }}>
{FILTERS.map((f) => (

View file

@ -88,6 +88,142 @@ function CommentsSection({ token, dispatchId, comments, onAdded }) {
);
}
function UpliftRequestsSection({ token, dispatchId, currentNTE, upliftRequests, locked, onChanged }) {
const [requested, setRequested] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const requests = upliftRequests || [];
const pending = requests.find((r) => r.status === "Pending");
async function submit(e) {
e.preventDefault();
const amt = parseFloat(requested);
if (!Number.isFinite(amt) || amt <= 0) {
alert("Enter a valid requested NTE amount.");
return;
}
if (currentNTE != null && amt <= Number(currentNTE)) {
alert(`Requested NTE must be greater than the current NTE ($${Number(currentNTE).toFixed(2)}).`);
return;
}
if (!reason.trim()) {
alert("Please include a reason for the uplift request.");
return;
}
setSubmitting(true);
try {
await vendorPortalApi.requestUplift(token, dispatchId, { requestedNTE: amt, reason: reason.trim() });
setRequested("");
setReason("");
await onChanged();
} catch (err) {
alert(err.message);
} finally {
setSubmitting(false);
}
}
async function cancel(requestId) {
if (!window.confirm("Cancel this uplift request?")) return;
try {
await vendorPortalApi.cancelUplift(token, dispatchId, requestId);
await onChanged();
} catch (err) {
alert(err.message);
}
}
return (
<>
{requests.length === 0 && !pending && (
<div className="vp-muted" style={{ marginBottom: 12 }}>
No uplift requests yet. Use the form below if the work will exceed the current NTE.
</div>
)}
{requests.map((r) => (
<div key={r.id} className="vp-uplift-row">
<div className="vp-row">
<strong>Requested</strong>
<span>${Number(r.requestedNTE).toFixed(2)} (was ${Number(r.currentNTE ?? 0).toFixed(2)})</span>
</div>
<div className="vp-row">
<strong>Status</strong>
<span>
<span className={`vp-uplift-status-${String(r.status).toLowerCase()}`}>{r.status}</span>
{r.requiredTier === 2 && r.status === "Pending" && (
<span className="vp-muted" style={{ marginLeft: 8 }}>· Manager approval required</span>
)}
</span>
</div>
{r.vendorReason && (
<div className="vp-row"><strong>Reason</strong><span>{r.vendorReason}</span></div>
)}
<div className="vp-row">
<strong>Requested</strong>
<span>{formatDateTime(r.requestedAt)}</span>
</div>
{r.decidedAt && (
<div className="vp-row">
<strong>Decided</strong>
<span>
{formatDateTime(r.decidedAt)}
{r.decidedByName ? ` by ${r.decidedByName}` : ""}
</span>
</div>
)}
{r.decisionNote && (
<div className="vp-row"><strong>Note</strong><span>{r.decisionNote}</span></div>
)}
{r.status === "Pending" && (
<div className="vp-actions">
<button type="button" className="vp-btn vp-btn-ghost" onClick={() => cancel(r.id)}>
Cancel Request
</button>
</div>
)}
</div>
))}
{!locked && !pending && (
<form onSubmit={submit} style={{ marginTop: 12 }}>
<div className="vp-row">
<strong>Current NTE</strong>
<span>${Number(currentNTE ?? 0).toFixed(2)}</span>
</div>
<div className="vp-row">
<strong>New total NTE</strong>
<input
type="number"
step="0.01"
min="0"
className="vp-input"
value={requested}
onChange={(e) => setRequested(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="vp-row">
<strong>Reason</strong>
<textarea
className="vp-textarea"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Why is the uplift needed?"
/>
</div>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={submitting}>
{submitting ? "Submitting…" : "Request Uplift"}
</button>
</div>
</form>
)}
</>
);
}
function SignoffSection({ token, dispatchId, existing, signoffType, nameLabel, onAdded }) {
const [name, setName] = useState("");
const [method, setMethod] = useState("drawn");
@ -296,6 +432,21 @@ export default function DispatchDetailPage() {
/>
</div>
<div className="vp-card">
<h3>NTE Uplift Requests</h3>
<div className="vp-muted" style={{ marginBottom: 8 }}>
Need more than the current NTE? Submit a request and the dispatcher will review.
</div>
<UpliftRequestsSection
token={token}
dispatchId={dispatchId}
currentNTE={data.nteAmount}
upliftRequests={data.upliftRequests || []}
locked={locked}
onChanged={refresh}
/>
</div>
<div className="vp-card">
<h3>Customer Signoff</h3>
<div className="vp-muted" style={{ marginBottom: 8 }}>

View file

@ -0,0 +1,144 @@
import React, { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useVendorPortal } from "./VendorPortalContext";
import { vendorPortalApi } from "./api";
import { statusClass, formatDateTime } from "./statusHelpers";
const FILTERS = [
{ key: "open", label: "Open", match: (d) => d.status !== "Completed" && d.status !== "Verified" && d.status !== "Cancelled" },
{ key: "completed", label: "Completed", match: (d) => d.status === "Completed" || d.status === "Verified" },
{ key: "cancelled", label: "Cancelled", match: (d) => d.status === "Cancelled" },
{ key: "all", label: "All", match: () => true },
];
function fmtDate(value) {
if (!value) return "—";
const d = new Date(value);
if (isNaN(d)) return "—";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fmtMoney(value) {
if (value == null) return "—";
const n = Number(value);
if (isNaN(n)) return "—";
return `$${n.toFixed(2)}`;
}
export default function PosPage() {
const { token } = useVendorPortal();
const navigate = useNavigate();
const [dispatches, setDispatches] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [filter, setFilter] = useState("open");
const [search, setSearch] = useState("");
useEffect(() => {
setLoading(true);
vendorPortalApi
.listDispatches(token)
.then((data) => setDispatches(Array.isArray(data) ? data : []))
.catch((e) => setErr(e.message))
.finally(() => setLoading(false));
}, [token]);
const activeFilter = FILTERS.find((f) => f.key === filter) ?? FILTERS[0];
const visible = useMemo(() => {
const needle = search.trim().toLowerCase();
return dispatches.filter((d) => {
if (!activeFilter.match(d)) return false;
if (!needle) return true;
return (
(d.poNumber || "").toLowerCase().includes(needle) ||
(d.dispatchNumber || "").toLowerCase().includes(needle) ||
(d.internalWONumber || "").toLowerCase().includes(needle) ||
(d.workOrderTitle || "").toLowerCase().includes(needle) ||
(d.locationName || "").toLowerCase().includes(needle)
);
});
}, [dispatches, activeFilter, search]);
return (
<>
<div className="vp-card">
<h2>Your Purchase Orders</h2>
<div className="vp-muted" style={{ marginBottom: 12 }}>
Every PO issued to your company, with current status and dollar value.
</div>
<div className="vp-actions" style={{ marginTop: 0, gap: 12, alignItems: "center", flexWrap: "wrap" }}>
{FILTERS.map((f) => (
<button
key={f.key}
className={`vp-btn ${filter === f.key ? "vp-btn-primary" : "vp-btn-ghost"}`}
onClick={() => setFilter(f.key)}
>
{f.label}
{" "}
<span style={{ opacity: 0.7, fontWeight: 400 }}>
({dispatches.filter(f.match).length})
</span>
</button>
))}
<input
className="vp-input"
placeholder="Search PO #, WO, location…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ marginLeft: "auto", minWidth: 240 }}
/>
</div>
</div>
{loading && <div className="vp-card vp-muted">Loading POs…</div>}
{err && <div className="vp-error">{err}</div>}
{!loading && !err && visible.length === 0 && (
<div className="vp-card vp-empty">No POs match this view.</div>
)}
{!loading && !err && visible.length > 0 && (
<div className="vp-card" style={{ padding: 0 }}>
<table className="vp-po-table">
<thead>
<tr>
<th>PO #</th>
<th>Dispatch #</th>
<th>Status</th>
<th>NTE</th>
<th>Location</th>
<th>Dispatched</th>
<th>Scheduled</th>
<th>Completed</th>
</tr>
</thead>
<tbody>
{visible.map((d) => (
<tr key={d.id} onClick={() => navigate(`../dispatch/${d.id}`)}>
<td>
<span className="vp-po-number">{d.poNumber || "—"}</span>
</td>
<td className="vp-po-muted-cell">{d.dispatchNumber}</td>
<td>
<span className={statusClass(d.status)}>{d.status}</span>
</td>
<td>{fmtMoney(d.nteAmount)}</td>
<td className="vp-po-muted-cell">
{d.locationName || "—"}
{d.locationCity ? ` · ${d.locationCity}` : ""}
{d.locationState ? `, ${d.locationState}` : ""}
</td>
<td className="vp-po-muted-cell">{d.dispatchedAt ? formatDateTime(d.dispatchedAt) : "—"}</td>
<td className="vp-po-muted-cell">{fmtDate(d.scheduledDate)}</td>
<td className="vp-po-muted-cell">{fmtDate(d.completedDate)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}

View file

@ -1,5 +1,5 @@
import React from "react";
import { Outlet } from "react-router-dom";
import { NavLink, Outlet, useLocation, useParams } from "react-router-dom";
import { VendorPortalProvider, useVendorPortal } from "./VendorPortalContext";
import "./vendor-portal.css";
@ -15,6 +15,30 @@ function Header() {
);
}
function Nav() {
const { token } = useParams();
const { pathname } = useLocation();
const onDetail = /\/dispatch\//.test(pathname);
return (
<nav className="vp-nav">
<div className="vp-nav-inner">
<NavLink
to={`/v/${token}/dashboard`}
className={({ isActive }) => `vp-nav-tab ${isActive || onDetail ? "active" : ""}`}
>
Work
</NavLink>
<NavLink
to={`/v/${token}/pos`}
className={({ isActive }) => `vp-nav-tab ${isActive ? "active" : ""}`}
>
POs
</NavLink>
</div>
</nav>
);
}
function Body() {
const { status, error } = useVendorPortal();
@ -33,9 +57,12 @@ function Body() {
}
return (
<div className="vp-container">
<Outlet />
</div>
<>
<Nav />
<div className="vp-container">
<Outlet />
</div>
</>
);
}

View file

@ -34,4 +34,8 @@ export const vendorPortalApi = {
call(token, `/dispatches/${id}/checklist/${itemId}`, { method: "POST", body: { isCompleted } }),
addSignoff: (token, id, payload) => call(token, `/dispatches/${id}/signoff`, { method: "POST", body: payload }),
addComment: (token, id, commentText) => call(token, `/dispatches/${id}/comments`, { method: "POST", body: { commentText } }),
requestUplift: (token, id, payload) =>
call(token, `/dispatches/${id}/uplift-request`, { method: "POST", body: payload }),
cancelUplift: (token, id, requestId) =>
call(token, `/dispatches/${id}/uplift-request/${requestId}/cancel`, { method: "POST" }),
};

View file

@ -235,3 +235,141 @@
}
.vp-back-link:hover { text-decoration: underline; }
.vp-uplift-row {
padding: 12px;
border: 1px solid #e4e7eb;
border-radius: 8px;
background: #fafbfc;
margin-bottom: 10px;
}
.vp-uplift-status-pending {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #fef3c7;
color: #92400e;
}
.vp-uplift-status-approved {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #d1fae5;
color: #065f46;
}
.vp-uplift-status-denied {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #fee2e2;
color: #991b1b;
}
.vp-uplift-status-cancelled {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #e5e7eb;
color: #4b5563;
}
.vp-nav {
background: #fff;
border-bottom: 1px solid #e4e7eb;
padding: 0 24px;
}
.vp-nav-inner {
max-width: 1080px;
margin: 0 auto;
display: flex;
gap: 4px;
}
.vp-nav-tab {
background: none;
border: none;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: #6b7280;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 120ms, border-color 120ms;
text-decoration: none;
display: inline-block;
}
.vp-nav-tab:hover {
color: #1f2d3d;
}
.vp-nav-tab.active {
color: #1f2d3d;
border-bottom-color: #2563eb;
}
.vp-po-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.vp-po-table th,
.vp-po-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid #f1f3f5;
}
.vp-po-table th {
background: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 13px;
}
.vp-po-table tbody tr {
cursor: pointer;
transition: background 120ms;
}
.vp-po-table tbody tr:hover {
background: #f9fafb;
}
.vp-po-table tbody tr:last-child td {
border-bottom: none;
}
.vp-po-number {
font-weight: 600;
color: #1f2d3d;
}
.vp-po-muted-cell {
color: #6b7280;
font-size: 13px;
}
.vp-po-uplift-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #fef3c7;
color: #92400e;
margin-left: 6px;
}

View file

@ -0,0 +1,110 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router";
import usePaginatedList from "../../../hooks/usePaginatedList";
import { fetchDispatchesPaged } from "./api";
import { vendorApi } from "../../../services/api";
import Table from "./Table";
import "./styles.css";
const STATUS_OPTIONS = ["", "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled"];
export default function VendorPoList() {
const navigate = useNavigate();
const [status, setStatus] = useState("");
const [vendorId, setVendorId] = useState("");
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
const [vendors, setVendors] = useState([]);
const extraParams = { status, vendorId, dateFrom, dateTo };
const {
rows, search, setSearch, page, setPage, pageSize, setPageSize,
totalCount, loading, err,
} = usePaginatedList(fetchDispatchesPaged, extraParams);
useEffect(() => {
vendorApi.getAll({ page: 1, pageSize: 500 })
.then((res) => {
const data = res?.data ?? res?.Data ?? res;
const items = data?.items || data || [];
setVendors(
items.map((v) => ({
id: v.id ?? v.Id,
companyName: v.companyName ?? v.CompanyName ?? "",
})).filter((v) => v.companyName)
);
})
.catch(() => setVendors([]));
}, []);
const clearFilters = () => {
setStatus("");
setVendorId("");
setDateFrom("");
setDateTo("");
setPage(1);
};
return (
<section className="vendorPoListPage">
<div className="vendorPoListToolbar">
<div className="vendorPoListToolbarLeft">
<h2 className="vendorPoListTitle">Vendor POs</h2>
<span className="vendorPoListCount">{totalCount} total</span>
</div>
<div className="vendorPoListToolbarRight">
<input
className="vendorPoSearchInput"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Search by PO #, Dispatch #, vendor, WO #, location..."
/>
</div>
</div>
<div className="vendorPoListFilters">
<label>
Status:
<select value={status} onChange={(e) => { setStatus(e.target.value); setPage(1); }}>
{STATUS_OPTIONS.map((s) => (
<option key={s || "all"} value={s}>{s || "All"}</option>
))}
</select>
</label>
<label>
Vendor:
<select value={vendorId} onChange={(e) => { setVendorId(e.target.value); setPage(1); }}>
<option value="">All</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>{v.companyName}</option>
))}
</select>
</label>
<label>
From:
<input type="date" value={dateFrom} onChange={(e) => { setDateFrom(e.target.value); setPage(1); }} />
</label>
<label>
To:
<input type="date" value={dateTo} onChange={(e) => { setDateTo(e.target.value); setPage(1); }} />
</label>
<button type="button" className="vendorPoListFiltersClear" onClick={clearFilters}>
Clear filters
</button>
</div>
<Table
loading={loading}
error={err}
rows={rows}
onView={(item) => navigate(`/vendor-pos/${item.id}`)}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
/>
</section>
);
}

View file

@ -0,0 +1,91 @@
import React from "react";
import SharedTable from "../../../components/SharedTable";
function fmtDate(value) {
if (!value) return "";
const d = new Date(value);
if (isNaN(d)) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fmtMoney(value) {
if (value == null) return "";
const n = Number(value);
if (isNaN(n)) return "";
return `$${n.toFixed(2)}`;
}
export default function Table({ loading, error, rows, onView, page, setPage, pageSize, setPageSize, totalCount }) {
const columns = [
{
key: "poNumber",
label: "PO #",
render: (val, row) => (
<strong>
<a href="#" onClick={(e) => { e.preventDefault(); onView && onView(row); }}>
{val || "—"}
</a>
</strong>
),
},
{ key: "dispatchNumber", label: "Dispatch #" },
{ key: "vendorName", label: "Vendor" },
{ key: "primaryWOInternalNumber", label: "WO #" },
{
key: "workOrderCount",
label: "WOs",
render: (val) => (val == null ? "" : String(val)),
},
{ key: "locationName", label: "Location" },
{
key: "nteAmount",
label: "NTE",
render: (val) => fmtMoney(val),
},
{
key: "status",
label: "Status",
render: (val) => (
<span className={`vendorPoStatusPill vendorPoStatusPill-${String(val || "").toLowerCase().replace(/\s+/g, "-")}`}>
{val || "—"}
</span>
),
},
{
key: "pendingUpliftCount",
label: "Uplifts",
render: (val) => (val > 0 ? <span className="vendorPoUpliftBadge">{val} pending</span> : ""),
},
{
key: "dispatchedAt",
label: "Dispatched",
render: (val) => fmtDate(val),
},
{
key: "scheduledDate",
label: "Scheduled",
render: (val) => fmtDate(val),
},
{
key: "completedDate",
label: "Completed",
render: (val) => fmtDate(val),
},
];
return (
<SharedTable
rows={rows}
columns={columns}
onEdit={onView}
page={page}
setPage={setPage}
pageSize={pageSize}
setPageSize={setPageSize}
totalCount={totalCount}
loading={loading}
error={error}
/>
);
}

View file

@ -0,0 +1,31 @@
import { dispatchApi } from "../../../services/api";
export const fetchDispatchesPaged = async ({
search = "",
page = 1,
pageSize = 12,
sortBy = "dispatchedAt",
sortDir = "desc",
status = "",
vendorId = "",
dateFrom = "",
dateTo = "",
}) => {
const params = { page, pageSize, sortBy, sortDir };
if (search) params.search = search;
if (status) params.status = status;
if (vendorId) params.vendorId = vendorId;
if (dateFrom) params.dateFrom = dateFrom;
if (dateTo) params.dateTo = dateTo;
const res = await dispatchApi.list(params);
const data = res?.data ?? res?.Data ?? res;
const items = data?.items || [];
return {
rows: items,
page: data?.page ?? page,
pageSize: data?.pageSize ?? pageSize,
totalCount: data?.total ?? items.length,
totalPages: Math.max(1, Math.ceil((data?.total ?? items.length) / pageSize)),
};
};

View file

@ -0,0 +1,111 @@
.vendorPoListPage {
padding: 24px;
}
.vendorPoListToolbar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.vendorPoListToolbarLeft {
display: flex;
align-items: baseline;
gap: 12px;
}
.vendorPoListTitle {
margin: 0;
font-size: 22px;
color: #1f2937;
}
.vendorPoListCount {
color: #6b7280;
font-size: 13px;
}
.vendorPoListToolbarRight {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vendorPoSearchInput {
padding: 7px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
min-width: 240px;
background: #fff;
}
.vendorPoListFilters {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 10px 12px;
background: #f9fafb;
border: 1px solid #e4e7eb;
border-radius: 8px;
font-size: 13px;
color: #374151;
}
.vendorPoListFilters label {
display: flex;
align-items: center;
gap: 6px;
}
.vendorPoListFilters select,
.vendorPoListFilters input[type="date"] {
padding: 5px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
background: #fff;
}
.vendorPoListFiltersClear {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
font-size: 12px;
color: #374151;
}
.vendorPoStatusPill {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #e5e7eb;
color: #4b5563;
}
.vendorPoStatusPill-sent { background: #dbeafe; color: #1e40af; }
.vendorPoStatusPill-acknowledged { background: #e0e7ff; color: #3730a3; }
.vendorPoStatusPill-in-progress { background: #fef3c7; color: #92400e; }
.vendorPoStatusPill-completed { background: #d1fae5; color: #065f46; }
.vendorPoStatusPill-verified { background: #bbf7d0; color: #14532d; }
.vendorPoStatusPill-cancelled { background: #fee2e2; color: #991b1b; }
.vendorPoUpliftBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #fef3c7;
color: #92400e;
}

View file

@ -0,0 +1,297 @@
.vendorPoPage {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.vendorPoTopBar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
}
.vendorPoTopBar .vendorPoBack {
background: none;
border: none;
color: #2563eb;
cursor: pointer;
font-size: 14px;
padding: 0;
}
.vendorPoTopActions {
display: flex;
gap: 8px;
}
.vendorPoPrintBtn {
background: #fff;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
font-size: 14px;
color: #374151;
}
.vendorPoPrintBtn:hover { background: #f9fafb; }
.vendorPoHeader {
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
}
.vendorPoHeader h1 {
margin: 0 0 4px 0;
font-size: 24px;
color: #111827;
}
.vendorPoHeaderSub {
color: #6b7280;
font-size: 14px;
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.vendorPoStatusPill {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #e5e7eb;
color: #4b5563;
}
.vendorPoStatusPill-sent { background: #dbeafe; color: #1e40af; }
.vendorPoStatusPill-acknowledged { background: #e0e7ff; color: #3730a3; }
.vendorPoStatusPill-in-progress { background: #fef3c7; color: #92400e; }
.vendorPoStatusPill-completed { background: #d1fae5; color: #065f46; }
.vendorPoStatusPill-verified { background: #bbf7d0; color: #14532d; }
.vendorPoStatusPill-cancelled { background: #fee2e2; color: #991b1b; }
.vendorPoCard {
background: #fff;
border: 1px solid #e4e7eb;
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.vendorPoCard h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #1f2937;
border-bottom: 1px solid #f1f3f5;
padding-bottom: 8px;
}
.vendorPoInfoGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px 24px;
font-size: 14px;
}
.vendorPoInfoItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.vendorPoInfoLabel {
font-size: 12px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.vendorPoInfoValue {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
.vendorPoTable {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.vendorPoTable th,
.vendorPoTable td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #f1f3f5;
}
.vendorPoTable th {
background: #f9fafb;
font-weight: 600;
color: #374151;
font-size: 13px;
}
.vendorPoTable tr:last-child td {
border-bottom: none;
}
.vendorPoTable a {
color: #2563eb;
text-decoration: none;
}
.vendorPoTable a:hover { text-decoration: underline; }
.vendorPoChecklistItem {
display: flex;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid #f1f3f5;
font-size: 14px;
}
.vendorPoChecklistItem:last-child { border-bottom: none; }
.vendorPoChecklistItem.done { color: #6b7280; text-decoration: line-through; }
.vendorPoSignoffGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.vendorPoSignoff {
border: 1px solid #e4e7eb;
border-radius: 6px;
padding: 12px;
background: #fafbfc;
}
.vendorPoSignoff h4 {
margin: 0 0 8px 0;
font-size: 13px;
text-transform: uppercase;
color: #374151;
letter-spacing: 0.03em;
}
.vendorPoSignature {
margin-top: 8px;
max-width: 100%;
max-height: 120px;
border: 1px solid #e4e7eb;
background: #fff;
padding: 4px;
}
.vendorPoTypedSignature {
font-family: "Dancing Script", "Great Vibes", cursive;
font-size: 28px;
color: #1f2937;
padding: 8px 0;
}
.vendorPoComment {
padding: 8px 0;
border-bottom: 1px solid #f1f3f5;
font-size: 14px;
}
.vendorPoComment:last-child { border-bottom: none; }
.vendorPoComment-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
font-size: 12px;
color: #6b7280;
}
.vendorPoComment-author {
font-weight: 600;
color: #374151;
}
.vendorPoComment-body {
white-space: pre-wrap;
color: #1f2937;
}
.vendorPoUpliftRow {
padding: 10px 12px;
border: 1px solid #e4e7eb;
border-radius: 6px;
margin-bottom: 8px;
background: #fafbfc;
font-size: 14px;
}
.vendorPoUpliftRow-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.vendorPoUpliftAmount {
font-weight: 600;
color: #1f2937;
}
.vendorPoUpliftStatus {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.vendorPoUpliftStatus-pending { background: #fef3c7; color: #92400e; }
.vendorPoUpliftStatus-approved { background: #d1fae5; color: #065f46; }
.vendorPoUpliftStatus-denied { background: #fee2e2; color: #991b1b; }
.vendorPoUpliftStatus-cancelled { background: #e5e7eb; color: #4b5563; }
.vendorPoEmpty {
color: #6b7280;
font-style: italic;
padding: 6px 0;
font-size: 14px;
}
.vendorPoAuditTable td:first-child {
color: #6b7280;
white-space: nowrap;
font-size: 13px;
}
@media print {
body {
background: #fff !important;
}
.sidebar,
.topbar,
.vendorPoBack,
.vendorPoPrintBtn,
.vendorPoTopActions {
display: none !important;
}
.app.nav-collapsed .mainArea,
.app .mainArea {
margin-left: 0 !important;
}
.vendorPoPage {
max-width: 100% !important;
padding: 0 !important;
}
.vendorPoCard,
.vendorPoHeader {
break-inside: avoid;
box-shadow: none !important;
border: 1px solid #ccc !important;
}
.vendorPoPage a {
color: #000 !important;
text-decoration: none !important;
}
}

View file

@ -0,0 +1,363 @@
import React, { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { toast } from "react-toastify";
import { dispatchApi, upliftApi } from "../../../services/api";
import "./VendorPoViewPage.css";
function fmtDate(value) {
if (!value) return "";
const d = new Date(value);
if (isNaN(d)) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fmtDateTime(value) {
if (!value) return "";
const d = new Date(value);
if (isNaN(d)) return "";
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtMoney(value) {
if (value == null) return "—";
const n = Number(value);
if (isNaN(n)) return "—";
return `$${n.toFixed(2)}`;
}
function Info({ label, children }) {
return (
<div className="vendorPoInfoItem">
<span className="vendorPoInfoLabel">{label}</span>
<span className="vendorPoInfoValue">{children == null || children === "" ? "—" : children}</span>
</div>
);
}
export default function VendorPoViewPage() {
const { id } = useParams();
const navigate = useNavigate();
const [dispatch, setDispatch] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState("");
const load = useCallback(async () => {
setLoading(true);
setErr("");
try {
const res = await dispatchApi.getById(id);
const data = res?.data ?? res?.Data ?? res;
setDispatch(data);
} catch (e) {
setErr(e.message || "Failed to load");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
const handleApproveUplift = async (u) => {
const note = window.prompt("Optional approval note:", "") || "";
if (!window.confirm(`Approve uplift to ${fmtMoney(u.requestedNTE)}?`)) return;
try {
await upliftApi.approve(u.id, note);
toast.success("Uplift approved — NTE updated");
load();
} catch (e) {
toast.error(e.message || "Failed to approve");
}
};
const handleDenyUplift = async (u) => {
const note = window.prompt("Denial reason (required):", "");
if (!note || !note.trim()) {
toast.error("A reason is required when denying.");
return;
}
try {
await upliftApi.deny(u.id, note.trim());
toast.success("Uplift denied");
load();
} catch (e) {
toast.error(e.message || "Failed to deny");
}
};
if (loading) return <div className="vendorPoPage"><div className="vendorPoCard">Loading…</div></div>;
if (err) return <div className="vendorPoPage"><div className="vendorPoCard" style={{ color: "#991b1b" }}>{err}</div></div>;
if (!dispatch) return <div className="vendorPoPage"><div className="vendorPoCard">Not found</div></div>;
const vendor = dispatch.vendor || dispatch.Vendor;
const primaryLocation = dispatch.primaryLocation || dispatch.PrimaryLocation;
const workOrders = dispatch.workOrders || dispatch.WorkOrders || [];
const checklist = dispatch.checklist || dispatch.Checklist || [];
const signoffs = dispatch.signoffs || dispatch.Signoffs || [];
const comments = dispatch.comments || dispatch.Comments || [];
const upliftRequests = dispatch.upliftRequests || dispatch.UpliftRequests || [];
const auditLog = dispatch.auditLog || dispatch.AuditLog || [];
const statusClass = String(dispatch.status || dispatch.Status || "").toLowerCase().replace(/\s+/g, "-");
return (
<section className="vendorPoPage">
<div className="vendorPoTopBar">
<button className="vendorPoBack" onClick={() => navigate(-1)}>← Back</button>
<div className="vendorPoTopActions">
<button className="vendorPoPrintBtn" onClick={() => window.print()}>Print</button>
</div>
</div>
<div className="vendorPoHeader">
<h1>{dispatch.poNumber || dispatch.PONumber || "(no PO #)"}</h1>
<div className="vendorPoHeaderSub">
<span>Dispatch {dispatch.dispatchNumber || dispatch.DispatchNumber || "—"}</span>
<span className={`vendorPoStatusPill vendorPoStatusPill-${statusClass}`}>{dispatch.status || dispatch.Status || "—"}</span>
{vendor?.companyName && <span>· {vendor.companyName}</span>}
{(dispatch.nteAmount ?? dispatch.NTEAmount) != null && (
<span>· NTE {fmtMoney(dispatch.nteAmount ?? dispatch.NTEAmount)}</span>
)}
</div>
</div>
<div className="vendorPoCard">
<h3>Vendor</h3>
{vendor ? (
<div className="vendorPoInfoGrid">
<Info label="Company">{vendor.companyName}</Info>
<Info label="Contact">{vendor.contactName}</Info>
<Info label="Email">{vendor.email}</Info>
<Info label="Phone">{vendor.phone}</Info>
<Info label="Address">
{[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
</Info>
<Info label="Trades">{vendor.tradeSpecialties}</Info>
</div>
) : (
<div className="vendorPoEmpty">No vendor attached.</div>
)}
</div>
<div className="vendorPoCard">
<h3>PO Details</h3>
<div className="vendorPoInfoGrid">
<Info label="PO #">{dispatch.poNumber || dispatch.PONumber}</Info>
<Info label="Dispatch #">{dispatch.dispatchNumber || dispatch.DispatchNumber}</Info>
<Info label="Status">{dispatch.status || dispatch.Status}</Info>
<Info label="NTE">{fmtMoney(dispatch.nteAmount ?? dispatch.NTEAmount)}</Info>
<Info label="Dispatched">{fmtDateTime(dispatch.dispatchedAt ?? dispatch.DispatchedAt)}</Info>
<Info label="Acknowledged">{fmtDateTime(dispatch.acknowledgedAt ?? dispatch.AcknowledgedAt)}</Info>
<Info label="Scheduled">{fmtDate(dispatch.scheduledDate ?? dispatch.ScheduledDate)}</Info>
<Info label="Completed">{fmtDate(dispatch.completedDate ?? dispatch.CompletedDate)}</Info>
<Info label="Verified">{fmtDateTime(dispatch.verifiedAt ?? dispatch.VerifiedAt)}</Info>
<Info label="Email sent">{(dispatch.emailSent ?? dispatch.EmailSent) ? "Yes" : "No"}</Info>
</div>
{(dispatch.description || dispatch.Description) && (
<div style={{ marginTop: 12 }}>
<div className="vendorPoInfoLabel">Description</div>
<div style={{ whiteSpace: "pre-wrap", marginTop: 4 }}>{dispatch.description || dispatch.Description}</div>
</div>
)}
</div>
{primaryLocation && (
<div className="vendorPoCard">
<h3>Site</h3>
<div className="vendorPoInfoGrid">
<Info label="Location">{primaryLocation.name}</Info>
<Info label="Address">
{[primaryLocation.address, primaryLocation.city, primaryLocation.state, primaryLocation.zipCode].filter(Boolean).join(", ")}
</Info>
</div>
</div>
)}
<div className="vendorPoCard">
<h3>Linked Work Orders ({workOrders.length})</h3>
{workOrders.length === 0 ? (
<div className="vendorPoEmpty">No work orders linked.</div>
) : (
<table className="vendorPoTable">
<thead>
<tr>
<th>WO #</th>
<th>Title</th>
<th>Trade</th>
<th>Sub-Trade</th>
<th>Problem</th>
<th>Priority</th>
<th>Status</th>
<th>Location</th>
</tr>
</thead>
<tbody>
{workOrders.map((wo) => (
<tr key={wo.workOrderId}>
<td>
<a href={`/workorders/${wo.workOrderId}`} onClick={(e) => { e.preventDefault(); navigate(`/workorders/${wo.workOrderId}`); }}>
{wo.internalWONumber || wo.workOrderId}
</a>
</td>
<td>{wo.title}</td>
<td>{wo.trade}</td>
<td>{wo.subTrade}</td>
<td>{wo.problem}</td>
<td>{wo.priority}</td>
<td>{wo.status}</td>
<td>{wo.locationName}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="vendorPoCard">
<h3>NTE Uplift History ({upliftRequests.length})</h3>
{upliftRequests.length === 0 ? (
<div className="vendorPoEmpty">No uplift requests on this PO.</div>
) : (
upliftRequests.map((u) => {
const statusKey = String(u.status || "").toLowerCase();
return (
<div key={u.id} className="vendorPoUpliftRow">
<div className="vendorPoUpliftRow-header">
<div className="vendorPoUpliftAmount">
{fmtMoney(u.currentNTE)} → {fmtMoney(u.requestedNTE)}
{" "}
<small style={{ color: "#6b7280", fontWeight: "normal" }}>
(Δ {fmtMoney(u.delta ?? (Number(u.requestedNTE) - Number(u.currentNTE || 0)))}, Tier {u.requiredTier})
</small>
</div>
<span className={`vendorPoUpliftStatus vendorPoUpliftStatus-${statusKey}`}>{u.status}</span>
</div>
{u.vendorReason && (
<div style={{ marginTop: 6, fontSize: 13, color: "#374151" }}>
<strong>Reason:</strong> {u.vendorReason}
</div>
)}
<div style={{ marginTop: 4, fontSize: 12, color: "#6b7280" }}>
Requested {fmtDateTime(u.requestedAt)}
{u.requestedByVendorName ? ` by ${u.requestedByVendorName}` : ""}
{u.decidedAt && (
<> · Decided {fmtDateTime(u.decidedAt)}{u.decidedByName ? ` by ${u.decidedByName}` : ""}</>
)}
</div>
{u.decisionNote && (
<div style={{ marginTop: 4, fontSize: 13, color: "#374151" }}>
<strong>Note:</strong> {u.decisionNote}
</div>
)}
{u.status === "Pending" && (
<div style={{ marginTop: 8, display: "flex", gap: 8 }}>
<button className="btn btn-success" onClick={() => handleApproveUplift(u)}>Approve</button>
<button className="btn btn-danger" onClick={() => handleDenyUplift(u)}>Deny</button>
</div>
)}
</div>
);
})
)}
</div>
<div className="vendorPoCard">
<h3>Checklist ({checklist.length})</h3>
{checklist.length === 0 ? (
<div className="vendorPoEmpty">No checklist items.</div>
) : (
<div>
{checklist.map((item) => (
<div key={item.id} className={`vendorPoChecklistItem ${item.isCompleted ? "done" : ""}`}>
<span>{item.isCompleted ? "☑" : "☐"}</span>
<span style={{ flex: 1 }}>{item.itemText}</span>
{item.isCompleted && item.completedBy && (
<span style={{ fontSize: 12, color: "#6b7280" }}>
{item.completedBy} · {fmtDateTime(item.completedAt)}
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="vendorPoCard">
<h3>Signoffs ({signoffs.length})</h3>
{signoffs.length === 0 ? (
<div className="vendorPoEmpty">No signoffs captured.</div>
) : (
<div className="vendorPoSignoffGrid">
{signoffs.map((s) => (
<div key={s.id} className="vendorPoSignoff">
<h4>{s.signoffType} Signoff</h4>
<div><strong>{s.name}</strong></div>
<div style={{ fontSize: 12, color: "#6b7280" }}>
{s.signatureMethod} · {fmtDateTime(s.signedAt)}
</div>
{s.signatureMethod === "drawn" && s.signature ? (
<img className="vendorPoSignature" src={s.signature} alt={`${s.signoffType} signature`} />
) : s.signature ? (
<div className="vendorPoTypedSignature">{s.signature}</div>
) : null}
</div>
))}
</div>
)}
</div>
<div className="vendorPoCard">
<h3>Comments ({comments.length})</h3>
{comments.length === 0 ? (
<div className="vendorPoEmpty">No comments on this PO.</div>
) : (
comments.map((c) => (
<div key={c.id} className="vendorPoComment">
<div className="vendorPoComment-header">
<span className="vendorPoComment-author">
{c.userName || c.commenter || "—"}
{c.commentType ? ` · ${c.commentType}` : ""}
</span>
<span>{fmtDateTime(c.createdDate)}</span>
</div>
<div className="vendorPoComment-body">{c.commenttext}</div>
</div>
))
)}
</div>
<div className="vendorPoCard">
<h3>Audit Log ({auditLog.length})</h3>
{auditLog.length === 0 ? (
<div className="vendorPoEmpty">No audit entries.</div>
) : (
<table className="vendorPoTable vendorPoAuditTable">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Field</th>
<th>Old → New</th>
<th>By</th>
</tr>
</thead>
<tbody>
{auditLog.map((a) => (
<tr key={a.id}>
<td>{fmtDateTime(a.createdAt)}</td>
<td>{a.action}</td>
<td>{a.fieldName}</td>
<td>
{a.oldValue || "—"} → {a.newValue || "—"}
</td>
<td>{a.userName}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
}

View file

@ -1048,3 +1048,75 @@
text-align: center;
color: #999;
}
.dispatchUpliftPanel {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.dispatchUpliftRow {
padding: 12px;
border: 1px solid #e4e7eb;
border-radius: 8px;
background: #fafbfc;
}
.dispatchUpliftHeader {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.dispatchUpliftAmount {
font-weight: 600;
color: #1f2937;
}
.dispatchUpliftDelta {
color: #059669;
font-weight: 500;
}
.dispatchUpliftTier {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-weight: 600;
}
.dispatchUpliftPill {
display: inline-block;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.dispatchUpliftPill-pending { background: #fef3c7; color: #92400e; }
.dispatchUpliftPill-approved { background: #d1fae5; color: #065f46; }
.dispatchUpliftPill-denied { background: #fee2e2; color: #991b1b; }
.dispatchUpliftPill-cancelled { background: #e5e7eb; color: #4b5563; }
.dispatchUpliftReason {
font-size: 13px;
color: #374151;
margin-top: 4px;
}
.dispatchUpliftMeta {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
}
.dispatchUpliftActions {
display: flex;
gap: 8px;
margin-top: 10px;
}

View file

@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
import { useParams, useNavigate } from "react-router";
import { toast } from "react-toastify";
import { getWorkOrderById, addComment, changeWorkOrderStatus, fetchUsers, changeWorkOrderAssignment, fetchVendorsDropdown, dispatchToVendor, getDispatchById, updateDispatch, addDispatchComment, fetchTaskTemplates, updateChecklistItem, addChecklistItem, addDispatchSignoff, verifyDispatch } from "../api";
import { upliftApi } from "../../../services/api";
import { getCurrentUser } from "../../../services/authService";
import { timeSince, timeBetween, waitTimeColor } from "../../../utils/timeSince";
import SignatureCapture from "../../../components/SignatureCapture";
@ -177,8 +178,18 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
const [sendEmail, setSendEmail] = useState(true);
const [sendingComment, setSendingComment] = useState(false);
const [sigCapture, setSigCapture] = useState(null);
const [uplifts, setUplifts] = useState([]);
const bottomRef = useRef(null);
const loadUplifts = async () => {
try {
const res = await upliftApi.listForDispatch(dispatchId);
setUplifts(res?.data ?? res ?? []);
} catch {
setUplifts([]);
}
};
const loadDispatch = async () => {
try {
const data = await getDispatchById(dispatchId);
@ -190,6 +201,7 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
completedDate: data.completedDate ? data.completedDate.split("T")[0] : "",
description: data.description || "",
});
await loadUplifts();
} catch {
toast.error("Failed to load dispatch");
} finally {
@ -197,6 +209,35 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
}
};
const handleApproveUplift = async (req) => {
const note = window.prompt("Optional approval note:", "") || "";
if (!window.confirm(`Approve uplift to $${Number(req.requestedNTE).toFixed(2)}?`)) return;
try {
await upliftApi.approve(req.id, note);
toast.success("Uplift approved — NTE updated");
await loadDispatch();
onUpdated();
} catch (err) {
toast.error(err.message || "Failed to approve uplift");
}
};
const handleDenyUplift = async (req) => {
const note = window.prompt("Denial reason (required):", "");
if (!note || !note.trim()) {
toast.error("A reason is required when denying.");
return;
}
try {
await upliftApi.deny(req.id, note.trim());
toast.success("Uplift denied");
await loadDispatch();
onUpdated();
} catch (err) {
toast.error(err.message || "Failed to deny uplift");
}
};
useEffect(() => { loadDispatch(); }, [dispatchId]);
useEffect(() => { bottomRef.current?.scrollIntoView(); }, [dispatch?.comments?.length]);
@ -458,6 +499,59 @@ function DispatchDetailModal({ dispatchId, onClose, onUpdated }) {
/>
)}
<h4>NTE Uplift Requests</h4>
<div className="dispatchUpliftPanel">
{uplifts.length === 0 && <p className="woNoComments">No uplift requests.</p>}
{uplifts.map((u) => {
const locked = dispatch.status === "Verified" || dispatch.status === "Cancelled";
const canDecide = u.canDecide && u.status === "Pending" && !locked;
return (
<div key={u.id} className={`dispatchUpliftRow dispatchUpliftStatus-${String(u.status).toLowerCase()}`}>
<div className="dispatchUpliftHeader">
<span className={`dispatchUpliftPill dispatchUpliftPill-${String(u.status).toLowerCase()}`}>{u.status}</span>
{u.requiredTier === 2 && <span className="dispatchUpliftTier">Tier 2 · Manager approval</span>}
<span className="dispatchUpliftAmount">
${Number(u.currentNTE ?? 0).toFixed(2)} → ${Number(u.requestedNTE).toFixed(2)}
<span className="dispatchUpliftDelta"> (+${Number(u.delta).toFixed(2)})</span>
</span>
</div>
{u.vendorReason && <div className="dispatchUpliftReason"><strong>Reason:</strong> {u.vendorReason}</div>}
<div className="dispatchUpliftMeta">
Requested {u.requestedAt ? formatDateTime(u.requestedAt) : ""}
{u.requestedByVendorName ? ` by ${u.requestedByVendorName}` : ""}
</div>
{u.decidedAt && (
<div className="dispatchUpliftMeta">
Decided {formatDateTime(u.decidedAt)}
{u.decidedByName ? ` by ${u.decidedByName}` : ""}
</div>
)}
{u.decisionNote && <div className="dispatchUpliftReason"><strong>Note:</strong> {u.decisionNote}</div>}
{u.status === "Pending" && (
<div className="dispatchUpliftActions">
<button
className="btn btn-success"
disabled={!canDecide}
title={!u.canDecide ? `Requires Tier ${u.requiredTier} role` : ""}
onClick={() => handleApproveUplift(u)}
>
Approve
</button>
<button
className="btn btn-danger"
disabled={!canDecide}
title={!u.canDecide ? `Requires Tier ${u.requiredTier} role` : ""}
onClick={() => handleDenyUplift(u)}
>
Deny
</button>
</div>
)}
</div>
);
})}
</div>
<h4>Vendor Communication</h4>
<div className="dispatchThreadContainer">
<div className="dispatchThreadScroll">

View file

@ -152,5 +152,17 @@ export const taskTemplateApi = {
delete: (id) => api.delete(`/tasklisttemplates/${id}`),
};
export const upliftApi = {
list: (params) => api.get('/uplifts', params),
listForDispatch: (dispatchId) => api.get(`/uplifts/dispatch/${dispatchId}`),
approve: (id, note) => api.post(`/uplifts/${id}/approve`, { note }),
deny: (id, note) => api.post(`/uplifts/${id}/deny`, { note }),
};
export const dispatchApi = {
list: (params) => api.get('/dispatches', params),
getById: (id) => api.get(`/dispatches/${id}`),
};
export default api;