- Delete /inventory/items/[id] — items expand inline in the list - Move SiteSelect from deleted [id] folder to components/inventory/site-select - Fix admin product detail page import to use new shared path - Fix items-table: Fragment key prop, restore Link import, plain text item names - Fix vendor-items-table: remove broken link to deleted item detail page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
/* Pelagia Portal — page components */
|
|
|
|
const { useState: useS, useEffect: useE, useMemo: useM } = React;
|
|
|
|
/* ═══════════════════ LOGIN ═══════════════════ */
|
|
const LoginPage = ({ onLogin }) => (
|
|
<div className="login-shell">
|
|
<div className="login-card">
|
|
<div className="login-brand">
|
|
<div className="brand-mark" />
|
|
<div>
|
|
<div className="brand-name" style={{ fontWeight: 600 }}>Pelagia Portal</div>
|
|
<div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>Internal · Purchase Order Management</div>
|
|
</div>
|
|
</div>
|
|
<div className="form-row" style={{ gap: 12 }}>
|
|
<div className="field">
|
|
<label className="field-label">Email</label>
|
|
<input className="input" defaultValue="anjali.k@pelagia.co" />
|
|
</div>
|
|
<div className="field">
|
|
<label className="field-label">Password</label>
|
|
<input className="input" type="password" defaultValue="••••••••••" />
|
|
</div>
|
|
<button className="btn maritime" style={{ height: 36, marginTop: 6, justifyContent: "center" }} onClick={onLogin}>
|
|
Sign in
|
|
</button>
|
|
<div className="faint" style={{ fontSize: 11.5, textAlign: "center" }}>
|
|
Contact an administrator to request access.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
/* ═══════════════════ DASHBOARD (Manager view) ═══════════════════ */
|
|
const Dashboard = ({ role, go }) => {
|
|
// Manager-rich view; switch headline content by role
|
|
const isMgr = role === "MANAGER" || role === "SUPERUSER";
|
|
const isAcct = role === "ACCOUNTS";
|
|
const isAuditor = role === "AUDITOR" || role === "ADMIN";
|
|
const isSubmitter = role === "TECHNICAL" || role === "MANNING";
|
|
|
|
const maxSpend = Math.max(...SPEND_TREND.map(s => s.v));
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<div>
|
|
<Crumbs items={["Dashboard"]} />
|
|
<h1 className="page-title">Good afternoon, Anjali</h1>
|
|
<div className="page-sub">Tuesday, May 12, 2026 · 14:38 IST · 3 vessels active at sea</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<button className="btn" onClick={() => go("history")}><Icon name="download" /> Export</button>
|
|
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> New PO</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isMgr && (
|
|
<>
|
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
|
<Stat label="Awaiting approval" value="6" sub={<><span className="trend-up">↑ 2</span> vs last week</>} onClick={() => go("approvals")} />
|
|
<Stat label="Approved · May" value="42" sub={<span className="muted">across 5 vessels</span>} />
|
|
<Stat label="Approved spend" value="₹14.2L" sub={<><span className="trend-dn">↓ 8%</span> vs Apr</>} />
|
|
<Stat label="Avg cycle" value="3.4d" sub={<span className="muted">submit → approve</span>} />
|
|
</div>
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
|
<Card title="Monthly spend — last 8 months" action={<span className="muted" style={{ fontSize: 11.5 }}>₹ in thousands</span>}>
|
|
<div className="bar-chart" style={{ marginBottom: 22 }}>
|
|
{SPEND_TREND.map((s, i) => (
|
|
<div key={i} className="bar"
|
|
data-label={s.m}
|
|
style={{ height: (s.v / maxSpend * 130) + "px", background: i === SPEND_TREND.length - 1 ? "var(--primary)" : "var(--primary-soft)" }}>
|
|
<span className="bar-val">{s.v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
<Card title="Spend by vessel — YTD" action={<span className="muted" style={{ fontSize: 11.5 }}>₹ in thousands</span>}>
|
|
<div style={{ paddingTop: 4 }}>
|
|
{VESSEL_SPEND.map((v, i) => {
|
|
const max = Math.max(...VESSEL_SPEND.map(x => x.v));
|
|
return (
|
|
<div className="hbar-row" key={i}>
|
|
<div className="name">{v.name}</div>
|
|
<div className="track"><div className="fill" style={{ width: (v.v / max * 100) + "%" }} /></div>
|
|
<div className="v">{v.v}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card title="Recently approved" action={<a className="muted" style={{ fontSize: 12 }} onClick={() => go("history")} href="#">View history →</a>} flush>
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>PO Number</th><th>Title</th><th>Vessel</th>
|
|
<th>Submitter</th><th>Approved</th><th className="num">Amount</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{ORDERS.filter(o => ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"].includes(o.status)).slice(0, 5).map(o => (
|
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
|
<td><span className="po-num">{o.id}</span></td>
|
|
<td>{o.title}</td>
|
|
<td className="muted">{o.vessel}</td>
|
|
<td className="muted">{o.submitter}</td>
|
|
<td className="muted">{o.approved || "—"}</td>
|
|
<td className="num">{inr(o.amount)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{isAcct && (
|
|
<>
|
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
|
<Stat label="Ready for payment" value="4" onClick={() => go("payments")} />
|
|
<Stat label="Awaiting confirmation" value="2" />
|
|
<Stat label="Value awaiting payment" value="₹3.84L" />
|
|
<Stat label="Paid this month" value="₹11.6L" />
|
|
</div>
|
|
<Card title="Payments queue" action={<a onClick={() => go("payments")} className="muted" style={{ fontSize: 12 }}>Open queue →</a>}>
|
|
<div className="muted">4 POs ready to send for payment. Open the queue to process.</div>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{isSubmitter && (
|
|
<>
|
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
|
<Stat label="Open orders" value="3" onClick={() => go("my-orders")} />
|
|
<Stat label="Pending approval" value="1" />
|
|
<Stat label="Completed" value="28" />
|
|
<Stat label="Total spend YTD" value="₹4.6L" />
|
|
</div>
|
|
<Card title="Open orders" flush>
|
|
<table className="table">
|
|
<thead><tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th className="num">Amount</th></tr></thead>
|
|
<tbody>
|
|
{ORDERS.filter(o => o.submitter === "Rajesh Pillai").slice(0, 4).map(o => (
|
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
|
<td><span className="po-num">{o.id}</span></td>
|
|
<td>{o.title}</td>
|
|
<td className="muted">{o.vessel}</td>
|
|
<td><Badge status={o.status} /></td>
|
|
<td className="num">{inr(o.amount)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{isAuditor && (
|
|
<>
|
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
|
<Stat label="Total POs YTD" value="284" onClick={() => go("history")} />
|
|
<Stat label="Total spend YTD" value="₹98.4L" />
|
|
<Stat label="Avg PO value" value="₹34.6K" />
|
|
<Stat label="Vessels" value="5" />
|
|
</div>
|
|
<Card title="Quick access">
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button className="btn" onClick={() => go("history")}><Icon name="file" /> Order history</button>
|
|
<button className="btn" onClick={() => go("vendors")}><Icon name="users" /> Vendor registry</button>
|
|
<button className="btn"><Icon name="download" /> Export PDF</button>
|
|
<button className="btn"><Icon name="download" /> Export CSV</button>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
/* ═══════════════════ MY ORDERS ═══════════════════ */
|
|
const MyOrders = ({ go }) => {
|
|
const mine = ORDERS.filter(o => o.submitter === "Rajesh Pillai");
|
|
const open = mine.filter(o => ["DRAFT","SUBMITTED","MGR_REVIEW","VENDOR_ID_PENDING","EDITS_REQUESTED"].includes(o.status));
|
|
const past = mine.filter(o => !["DRAFT","SUBMITTED","MGR_REVIEW","VENDOR_ID_PENDING","EDITS_REQUESTED"].includes(o.status));
|
|
// Add a couple of synthetic past orders to make it feel real
|
|
const pastFull = [...past, ...ORDERS.filter(o => ["CLOSED","PAID_DELIVERED","SENT_FOR_PAYMENT","MGR_APPROVED","REJECTED"].includes(o.status)).slice(0,3)];
|
|
|
|
return (
|
|
<>
|
|
<div className="page-head">
|
|
<div>
|
|
<Crumbs items={["Purchase Orders", "My Orders"]} />
|
|
<h1 className="page-title">My Purchase Orders</h1>
|
|
<div className="page-sub">{open.length} open · {pastFull.length} past</div>
|
|
</div>
|
|
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> New PO</button>
|
|
</div>
|
|
|
|
<h2 className="section-title">Open orders</h2>
|
|
<Card flush>
|
|
{open.length === 0 ? <div className="empty-state">No open orders.</div> : (
|
|
<table className="table">
|
|
<thead>
|
|
<tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th>Updated</th><th className="num">Amount</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{open.map(o => (
|
|
<React.Fragment key={o.id}>
|
|
<tr className="clickable" onClick={() => go("po-detail", o.id)}>
|
|
<td><span className="po-num">{o.id}</span></td>
|
|
<td>{o.title}</td>
|
|
<td className="muted">{o.vessel}</td>
|
|
<td><Badge status={o.status} /></td>
|
|
<td className="muted">{o.submitted || o.created || "—"}</td>
|
|
<td className="num">{inr(o.amount)}</td>
|
|
</tr>
|
|
{o.managerNote && (
|
|
<tr><td colSpan="6" style={{ background: "oklch(98% 0.015 95)", borderTop: 0 }}>
|
|
<div style={{ display: "flex", gap: 10, alignItems: "flex-start", padding: "2px 0" }}>
|
|
<Badge status="EDITS_REQUESTED" />
|
|
<div style={{ fontSize: 12 }}>
|
|
<strong>Manager note · Anjali Krishnan:</strong> <span className="muted">{o.managerNote}</span>
|
|
</div>
|
|
</div>
|
|
</td></tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
|
|
<h2 className="section-title">Past orders</h2>
|
|
<Card flush>
|
|
<table className="table">
|
|
<thead>
|
|
<tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th>Closed/Completed</th><th className="num">Amount</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{pastFull.map(o => (
|
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
|
<td><span className="po-num">{o.id}</span></td>
|
|
<td>{o.title}</td>
|
|
<td className="muted">{o.vessel}</td>
|
|
<td><Badge status={o.status} /></td>
|
|
<td className="muted">{o.closed || o.paid || o.approved || o.rejected || "—"}</td>
|
|
<td className="num">{inr(o.amount)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
</>
|
|
);
|
|
};
|
|
|
|
Object.assign(window, { LoginPage, Dashboard, MyOrders });
|