mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 06:53:12 +00:00
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:
parent
c98a905d4d
commit
2a6fe6f35e
19 changed files with 1988 additions and 6 deletions
16
src/App.js
16
src/App.js
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
135
src/pages/uplifts/UpliftQueuePage.css
Normal file
135
src/pages/uplifts/UpliftQueuePage.css
Normal 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;
|
||||
}
|
||||
182
src/pages/uplifts/UpliftQueuePage.js
Normal file
182
src/pages/uplifts/UpliftQueuePage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
144
src/pages/vendor-portal/PosPage.js
Normal file
144
src/pages/vendor-portal/PosPage.js
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
110
src/pages/vendor-pos/list/List.js
Normal file
110
src/pages/vendor-pos/list/List.js
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/pages/vendor-pos/list/Table.js
Normal file
91
src/pages/vendor-pos/list/Table.js
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
31
src/pages/vendor-pos/list/api.js
Normal file
31
src/pages/vendor-pos/list/api.js
Normal 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)),
|
||||
};
|
||||
};
|
||||
111
src/pages/vendor-pos/list/styles.css
Normal file
111
src/pages/vendor-pos/list/styles.css
Normal 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;
|
||||
}
|
||||
297
src/pages/vendor-pos/view/VendorPoViewPage.css
Normal file
297
src/pages/vendor-pos/view/VendorPoViewPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
363
src/pages/vendor-pos/view/VendorPoViewPage.js
Normal file
363
src/pages/vendor-pos/view/VendorPoViewPage.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue