mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 02:03:14 +00:00
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.
182 lines
6.3 KiB
JavaScript
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>
|
|
);
|
|
}
|