shoc-frontend-new/src/pages/uplifts/UpliftQueuePage.js
Adam Moussa 2a6fe6f35e 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.
2026-04-20 13:26:08 -04:00

182 lines
6.3 KiB
JavaScript

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>
);
}