diff --git a/src/pages/vendor-portal/PosPage.js b/src/pages/vendor-portal/PosPage.js
new file mode 100644
index 00000000..47aa6501
--- /dev/null
+++ b/src/pages/vendor-portal/PosPage.js
@@ -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 (
+ <>
+
+
Your Purchase Orders
+
+ Every PO issued to your company, with current status and dollar value.
+
+
+ {FILTERS.map((f) => (
+
+ ))}
+ setSearch(e.target.value)}
+ style={{ marginLeft: "auto", minWidth: 240 }}
+ />
+
+
+
+ {loading &&
Loading POs…
}
+ {err &&
{err}
}
+
+ {!loading && !err && visible.length === 0 && (
+
No POs match this view.
+ )}
+
+ {!loading && !err && visible.length > 0 && (
+
+
+
+
+ | PO # |
+ Dispatch # |
+ Status |
+ NTE |
+ Location |
+ Dispatched |
+ Scheduled |
+ Completed |
+
+
+
+ {visible.map((d) => (
+ navigate(`../dispatch/${d.id}`)}>
+ |
+ {d.poNumber || "—"}
+ |
+ {d.dispatchNumber} |
+
+ {d.status}
+ |
+ {fmtMoney(d.nteAmount)} |
+
+ {d.locationName || "—"}
+ {d.locationCity ? ` · ${d.locationCity}` : ""}
+ {d.locationState ? `, ${d.locationState}` : ""}
+ |
+ {d.dispatchedAt ? formatDateTime(d.dispatchedAt) : "—"} |
+ {fmtDate(d.scheduledDate)} |
+ {fmtDate(d.completedDate)} |
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/pages/vendor-portal/VendorPortalLayout.js b/src/pages/vendor-portal/VendorPortalLayout.js
index b64e0605..ec849c40 100644
--- a/src/pages/vendor-portal/VendorPortalLayout.js
+++ b/src/pages/vendor-portal/VendorPortalLayout.js
@@ -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 (
+
+ );
+}
+
function Body() {
const { status, error } = useVendorPortal();
@@ -33,9 +57,12 @@ function Body() {
}
return (
-
-
-
+ <>
+
+
+
+
+ >
);
}
diff --git a/src/pages/vendor-portal/api.js b/src/pages/vendor-portal/api.js
index 105ca30e..d67fff1c 100644
--- a/src/pages/vendor-portal/api.js
+++ b/src/pages/vendor-portal/api.js
@@ -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" }),
};
diff --git a/src/pages/vendor-portal/vendor-portal.css b/src/pages/vendor-portal/vendor-portal.css
index 0f6f5ecd..6238d97a 100644
--- a/src/pages/vendor-portal/vendor-portal.css
+++ b/src/pages/vendor-portal/vendor-portal.css
@@ -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;
+}
diff --git a/src/pages/vendor-pos/list/List.js b/src/pages/vendor-pos/list/List.js
new file mode 100644
index 00000000..3998d92d
--- /dev/null
+++ b/src/pages/vendor-pos/list/List.js
@@ -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 (
+
+
+
+
Vendor POs
+ {totalCount} total
+
+
+ { setSearch(e.target.value); setPage(1); }}
+ placeholder="Search by PO #, Dispatch #, vendor, WO #, location..."
+ />
+
+
+
+
+
+
+
+
+
+
+
+ navigate(`/vendor-pos/${item.id}`)}
+ page={page}
+ setPage={setPage}
+ pageSize={pageSize}
+ setPageSize={setPageSize}
+ totalCount={totalCount}
+ />
+
+ );
+}
diff --git a/src/pages/vendor-pos/list/Table.js b/src/pages/vendor-pos/list/Table.js
new file mode 100644
index 00000000..39438c2d
--- /dev/null
+++ b/src/pages/vendor-pos/list/Table.js
@@ -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) => (
+
+ { e.preventDefault(); onView && onView(row); }}>
+ {val || "—"}
+
+
+ ),
+ },
+ { 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) => (
+
+ {val || "—"}
+
+ ),
+ },
+ {
+ key: "pendingUpliftCount",
+ label: "Uplifts",
+ render: (val) => (val > 0 ? {val} pending : ""),
+ },
+ {
+ 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 (
+
+ );
+}
diff --git a/src/pages/vendor-pos/list/api.js b/src/pages/vendor-pos/list/api.js
new file mode 100644
index 00000000..066c21eb
--- /dev/null
+++ b/src/pages/vendor-pos/list/api.js
@@ -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)),
+ };
+};
diff --git a/src/pages/vendor-pos/list/styles.css b/src/pages/vendor-pos/list/styles.css
new file mode 100644
index 00000000..f8d2cc33
--- /dev/null
+++ b/src/pages/vendor-pos/list/styles.css
@@ -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;
+}
diff --git a/src/pages/vendor-pos/view/VendorPoViewPage.css b/src/pages/vendor-pos/view/VendorPoViewPage.css
new file mode 100644
index 00000000..8c43cdc8
--- /dev/null
+++ b/src/pages/vendor-pos/view/VendorPoViewPage.css
@@ -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;
+ }
+}
diff --git a/src/pages/vendor-pos/view/VendorPoViewPage.js b/src/pages/vendor-pos/view/VendorPoViewPage.js
new file mode 100644
index 00000000..aca89dcc
--- /dev/null
+++ b/src/pages/vendor-pos/view/VendorPoViewPage.js
@@ -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 (
+
+ {label}
+ {children == null || children === "" ? "—" : children}
+
+ );
+}
+
+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 ;
+ if (err) return ;
+ if (!dispatch) return ;
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
{dispatch.poNumber || dispatch.PONumber || "(no PO #)"}
+
+ Dispatch {dispatch.dispatchNumber || dispatch.DispatchNumber || "—"}
+ {dispatch.status || dispatch.Status || "—"}
+ {vendor?.companyName && · {vendor.companyName}}
+ {(dispatch.nteAmount ?? dispatch.NTEAmount) != null && (
+ · NTE {fmtMoney(dispatch.nteAmount ?? dispatch.NTEAmount)}
+ )}
+
+
+
+
+
Vendor
+ {vendor ? (
+
+ {vendor.companyName}
+ {vendor.contactName}
+ {vendor.email}
+ {vendor.phone}
+
+ {[vendor.address, vendor.city, vendor.state, vendor.zip].filter(Boolean).join(", ")}
+
+ {vendor.tradeSpecialties}
+
+ ) : (
+
No vendor attached.
+ )}
+
+
+
+
PO Details
+
+ {dispatch.poNumber || dispatch.PONumber}
+ {dispatch.dispatchNumber || dispatch.DispatchNumber}
+ {dispatch.status || dispatch.Status}
+ {fmtMoney(dispatch.nteAmount ?? dispatch.NTEAmount)}
+ {fmtDateTime(dispatch.dispatchedAt ?? dispatch.DispatchedAt)}
+ {fmtDateTime(dispatch.acknowledgedAt ?? dispatch.AcknowledgedAt)}
+ {fmtDate(dispatch.scheduledDate ?? dispatch.ScheduledDate)}
+ {fmtDate(dispatch.completedDate ?? dispatch.CompletedDate)}
+ {fmtDateTime(dispatch.verifiedAt ?? dispatch.VerifiedAt)}
+ {(dispatch.emailSent ?? dispatch.EmailSent) ? "Yes" : "No"}
+
+ {(dispatch.description || dispatch.Description) && (
+
+
Description
+
{dispatch.description || dispatch.Description}
+
+ )}
+
+
+ {primaryLocation && (
+
+
Site
+
+ {primaryLocation.name}
+
+ {[primaryLocation.address, primaryLocation.city, primaryLocation.state, primaryLocation.zipCode].filter(Boolean).join(", ")}
+
+
+
+ )}
+
+
+
Linked Work Orders ({workOrders.length})
+ {workOrders.length === 0 ? (
+
No work orders linked.
+ ) : (
+
+ )}
+
+
+
+
NTE Uplift History ({upliftRequests.length})
+ {upliftRequests.length === 0 ? (
+
No uplift requests on this PO.
+ ) : (
+ upliftRequests.map((u) => {
+ const statusKey = String(u.status || "").toLowerCase();
+ return (
+
+
+
+ {fmtMoney(u.currentNTE)} → {fmtMoney(u.requestedNTE)}
+ {" "}
+
+ (Δ {fmtMoney(u.delta ?? (Number(u.requestedNTE) - Number(u.currentNTE || 0)))}, Tier {u.requiredTier})
+
+
+
{u.status}
+
+ {u.vendorReason && (
+
+ Reason: {u.vendorReason}
+
+ )}
+
+ Requested {fmtDateTime(u.requestedAt)}
+ {u.requestedByVendorName ? ` by ${u.requestedByVendorName}` : ""}
+ {u.decidedAt && (
+ <> · Decided {fmtDateTime(u.decidedAt)}{u.decidedByName ? ` by ${u.decidedByName}` : ""}>
+ )}
+
+ {u.decisionNote && (
+
+ Note: {u.decisionNote}
+
+ )}
+ {u.status === "Pending" && (
+
+
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+
+
Checklist ({checklist.length})
+ {checklist.length === 0 ? (
+
No checklist items.
+ ) : (
+
+ {checklist.map((item) => (
+
+ {item.isCompleted ? "☑" : "☐"}
+ {item.itemText}
+ {item.isCompleted && item.completedBy && (
+
+ {item.completedBy} · {fmtDateTime(item.completedAt)}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
Signoffs ({signoffs.length})
+ {signoffs.length === 0 ? (
+
No signoffs captured.
+ ) : (
+
+ {signoffs.map((s) => (
+
+
{s.signoffType} Signoff
+
{s.name}
+
+ {s.signatureMethod} · {fmtDateTime(s.signedAt)}
+
+ {s.signatureMethod === "drawn" && s.signature ? (
+

+ ) : s.signature ? (
+
{s.signature}
+ ) : null}
+
+ ))}
+
+ )}
+
+
+
+
Comments ({comments.length})
+ {comments.length === 0 ? (
+
No comments on this PO.
+ ) : (
+ comments.map((c) => (
+
+
+
+ {c.userName || c.commenter || "—"}
+ {c.commentType ? ` · ${c.commentType}` : ""}
+
+ {fmtDateTime(c.createdDate)}
+
+
{c.commenttext}
+
+ ))
+ )}
+
+
+
+
Audit Log ({auditLog.length})
+ {auditLog.length === 0 ? (
+
No audit entries.
+ ) : (
+
+
+
+ | When |
+ Action |
+ Field |
+ Old → New |
+ By |
+
+
+
+ {auditLog.map((a) => (
+
+ | {fmtDateTime(a.createdAt)} |
+ {a.action} |
+ {a.fieldName} |
+
+ {a.oldValue || "—"} → {a.newValue || "—"}
+ |
+ {a.userName} |
+
+ ))}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/workorders/view/WorkOrderViewPage.css b/src/pages/workorders/view/WorkOrderViewPage.css
index 344df90a..6940db92 100644
--- a/src/pages/workorders/view/WorkOrderViewPage.css
+++ b/src/pages/workorders/view/WorkOrderViewPage.css
@@ -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;
+}
diff --git a/src/pages/workorders/view/WorkOrderViewPage.js b/src/pages/workorders/view/WorkOrderViewPage.js
index 04a78b7d..332bff69 100644
--- a/src/pages/workorders/view/WorkOrderViewPage.js
+++ b/src/pages/workorders/view/WorkOrderViewPage.js
@@ -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 }) {
/>
)}
+ NTE Uplift Requests
+
+ {uplifts.length === 0 &&
No uplift requests.
}
+ {uplifts.map((u) => {
+ const locked = dispatch.status === "Verified" || dispatch.status === "Cancelled";
+ const canDecide = u.canDecide && u.status === "Pending" && !locked;
+ return (
+
+
+ {u.status}
+ {u.requiredTier === 2 && Tier 2 · Manager approval}
+
+ ${Number(u.currentNTE ?? 0).toFixed(2)} → ${Number(u.requestedNTE).toFixed(2)}
+ (+${Number(u.delta).toFixed(2)})
+
+
+ {u.vendorReason &&
Reason: {u.vendorReason}
}
+
+ Requested {u.requestedAt ? formatDateTime(u.requestedAt) : ""}
+ {u.requestedByVendorName ? ` by ${u.requestedByVendorName}` : ""}
+
+ {u.decidedAt && (
+
+ Decided {formatDateTime(u.decidedAt)}
+ {u.decidedByName ? ` by ${u.decidedByName}` : ""}
+
+ )}
+ {u.decisionNote &&
Note: {u.decisionNote}
}
+ {u.status === "Pending" && (
+
+
+
+
+ )}
+
+ );
+ })}
+
+
Vendor Communication
diff --git a/src/services/api.js b/src/services/api.js
index a4279ace..4e4f3f58 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -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;