- 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>
451 lines
21 KiB
JavaScript
451 lines
21 KiB
JavaScript
/* Pelagia Portal — Sites, Cart, Vessels, Accounts, Users, Import PO */
|
||
|
||
/* ═══════════════════ SITES ═══════════════════ */
|
||
const SitesPage = ({ go }) => (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Inventory", "Sites"]} />
|
||
<h1 className="page-title">Sites</h1>
|
||
<div className="page-sub">{SITES.length} ports, depots, and offices that hold inventory</div>
|
||
</div>
|
||
<button className="btn maritime"><Icon name="plus" /> Add site</button>
|
||
</div>
|
||
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th><th>Code</th><th>Address</th>
|
||
<th className="num">Vessels</th><th className="num">Items</th>
|
||
<th>Location</th><th>Status</th><th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{SITES.map(s => (
|
||
<tr key={s.id} className="clickable" onClick={() => go("site-detail", s.id)}>
|
||
<td style={{ fontWeight: 500 }}>{s.name}</td>
|
||
<td className="mono" style={{ fontSize: 12 }}>{s.code}</td>
|
||
<td className="muted">{s.address}</td>
|
||
<td className="num mono">{s.vessels}</td>
|
||
<td className="num mono">{s.items}</td>
|
||
<td className="mono" style={{ fontSize: 11.5 }}>{["9.95° N","19.04° N","13.07° N","17.69° N"][SITES.indexOf(s)]} · {["76.26° E","72.85° E","80.26° E","83.21° E"][SITES.indexOf(s)]}</td>
|
||
<td><Badge className="closed" noDot>Active</Badge></td>
|
||
<td style={{ textAlign: "right" }}><button className="btn sm icon" onClick={e => e.stopPropagation()}><Icon name="edit" size={11} /></button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</>
|
||
);
|
||
|
||
/* ═══════════════════ SITE DETAIL ═══════════════════ */
|
||
const SiteDetailPage = ({ go, id }) => {
|
||
const s = SITES.find(x => x.id === id) || SITES[0];
|
||
const max = Math.max(...SITE_INVENTORY.map(i => i.qty));
|
||
|
||
return (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Inventory", "Sites", s.name]} />
|
||
<h1 className="page-title">{s.name}</h1>
|
||
<div className="page-sub">
|
||
<span className="mono">{s.code}</span><span className="dot-sep">·</span>
|
||
{s.address}<span className="dot-sep">·</span>
|
||
<span className="mono">9.9489° N · 76.2622° E</span>
|
||
</div>
|
||
</div>
|
||
<button className="btn"><Icon name="edit" /> Edit site</button>
|
||
</div>
|
||
|
||
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||
<Stat label="Vessels at site" value={s.vessels} />
|
||
<Stat label="Items tracked" value={s.items} />
|
||
<Stat label="Inventory value" value="₹18.6L" sub={<span className="muted">last calculated 12:30</span>} />
|
||
<Stat label="Consumption · 30d" value="412 ea" sub={<span className="muted">across all items</span>} />
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||
<Card title="Current stock" action={<span className="muted" style={{ fontSize: 11.5 }}>Quantity on hand</span>}>
|
||
<div style={{ paddingTop: 4 }}>
|
||
{SITE_INVENTORY.map((it, i) => (
|
||
<div className="hbar-row" key={i}>
|
||
<div className="name">{it.name}</div>
|
||
<div className="track"><div className="fill" style={{ width: (it.qty / max * 100) + "%" }} /></div>
|
||
<div className="v">{it.qty} ea</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="Consumption · last 12 days" action={<span className="muted" style={{ fontSize: 11.5 }}>Daily draw-down</span>}>
|
||
<div style={{ height: 160, position: "relative", paddingTop: 4 }}>
|
||
<svg viewBox="0 0 360 140" width="100%" height="140" preserveAspectRatio="none">
|
||
{/* grid lines */}
|
||
{[0, 1, 2, 3].map(i => (
|
||
<line key={i} x1="0" x2="360" y1={20 + i * 30} y2={20 + i * 30}
|
||
stroke="var(--line)" strokeWidth="1" strokeDasharray="2 3" />
|
||
))}
|
||
{[0, 1, 2, 3, 4].map(seriesIdx => {
|
||
const stroke = ["var(--primary)", "oklch(55% 0.09 30)", "oklch(50% 0.06 150)", "oklch(50% 0.07 280)", "oklch(55% 0.08 60)"][seriesIdx];
|
||
const pts = CONSUMPTION.map((d, i) => {
|
||
const x = (i / (CONSUMPTION.length - 1)) * 350 + 5;
|
||
const y = 130 - (d.vals[seriesIdx] / 4) * 110;
|
||
return `${x},${y}`;
|
||
}).join(" ");
|
||
return (
|
||
<polyline key={seriesIdx}
|
||
points={pts}
|
||
fill="none"
|
||
stroke={stroke}
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
opacity="0.85"
|
||
/>
|
||
);
|
||
})}
|
||
</svg>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", marginTop: 6, fontSize: 11 }}>
|
||
{SITE_INVENTORY.slice(0, 5).map((it, i) => (
|
||
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 5, color: "var(--muted)" }}>
|
||
<span style={{ width: 10, height: 2, background: ["var(--primary)", "oklch(55% 0.09 30)", "oklch(50% 0.06 150)", "oklch(50% 0.07 280)", "oklch(55% 0.08 60)"][i] }} />
|
||
{it.name.split("—")[0].trim().split(" ").slice(0,2).join(" ")}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||
<div>
|
||
<h2 className="section-title">Inventory</h2>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead><tr><th>Product</th><th className="num">Qty on hand</th><th>Last updated</th><th></th></tr></thead>
|
||
<tbody>
|
||
{SITE_INVENTORY.map((it, i) => (
|
||
<tr key={i} className="clickable" onClick={() => go("item-detail", "p1")}>
|
||
<td>{it.name}</td>
|
||
<td className="num mono">{it.qty} ea</td>
|
||
<td className="muted">{it.updated}</td>
|
||
<td style={{ textAlign: "right" }}><Icon name="arrowRight" size={11} /></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
|
||
<h2 className="section-title">Recent POs for this site</h2>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead><tr><th>PO Number</th><th>Status</th><th>Vendor</th><th>Created</th><th className="num">Amount</th></tr></thead>
|
||
<tbody>
|
||
{ORDERS.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><Badge status={o.status} /></td>
|
||
<td className="muted">{o.vendor || "—"}</td>
|
||
<td className="muted">{o.submitted || "—"}</td>
|
||
<td className="num">{inr(o.amount)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</div>
|
||
|
||
<div>
|
||
<Card title="Log consumption">
|
||
<div className="form-row" style={{ gap: 10 }}>
|
||
<div className="field">
|
||
<label className="field-label">Product</label>
|
||
<select className="select">{SITE_INVENTORY.map((i, idx) => <option key={idx}>{i.name}</option>)}</select>
|
||
</div>
|
||
<div className="form-row cols-2">
|
||
<div className="field">
|
||
<label className="field-label">Date</label>
|
||
<input className="input" type="date" defaultValue="2026-05-12" />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Quantity</label>
|
||
<input className="input mono" defaultValue="3" />
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Note (optional)</label>
|
||
<textarea className="textarea" style={{ minHeight: 56 }} placeholder="e.g. issued to MV Pelagia Voyager"></textarea>
|
||
</div>
|
||
<button className="btn maritime">Log consumption</button>
|
||
</div>
|
||
</Card>
|
||
|
||
<div style={{ height: 14 }} />
|
||
|
||
<Card title="Assigned vessels">
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||
<span className="tag-chip has-link">MV Pelagia Voyager</span>
|
||
<span className="tag-chip has-link">MV Coral Crescent</span>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════ VESSELS ═══════════════════ */
|
||
const VesselsPage = () => (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Inventory", "Vessels"]} />
|
||
<h1 className="page-title">Vessels</h1>
|
||
<div className="page-sub">{VESSELS.length} vessels in service</div>
|
||
</div>
|
||
<button className="btn maritime"><Icon name="plus" /> Add vessel</button>
|
||
</div>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead><tr><th>Name</th><th>IMO Number</th><th>Assigned site</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{VESSELS.map(v => (
|
||
<tr key={v.id}>
|
||
<td style={{ fontWeight: 500 }}>{v.name}</td>
|
||
<td className="mono">{v.imo}</td>
|
||
<td className="muted">{["Cochin Port Depot","Mumbai BPX Office","Visakhapatnam Yard","Chennai South Dock","Cochin Port Depot"][VESSELS.indexOf(v)]}</td>
|
||
<td><Badge className="closed" noDot>Active</Badge></td>
|
||
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</>
|
||
);
|
||
|
||
/* ═══════════════════ ACCOUNTS ═══════════════════ */
|
||
const AccountsPage = () => (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Administration", "Accounts"]} />
|
||
<h1 className="page-title">Accounts / Cost centres</h1>
|
||
<div className="page-sub">{ACCOUNTS.length} active cost centres</div>
|
||
</div>
|
||
<button className="btn maritime"><Icon name="plus" /> Add account</button>
|
||
</div>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead><tr><th>Code</th><th>Name</th><th>Description</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
{ACCOUNTS.map(a => (
|
||
<tr key={a.id}>
|
||
<td className="mono" style={{ fontSize: 12 }}>{a.code}</td>
|
||
<td style={{ fontWeight: 500 }}>{a.name}</td>
|
||
<td className="muted">{a.desc}</td>
|
||
<td><Badge className="closed" noDot>Active</Badge></td>
|
||
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</>
|
||
);
|
||
|
||
/* ═══════════════════ USERS ═══════════════════ */
|
||
const UsersPage = () => (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Administration", "Users"]} />
|
||
<h1 className="page-title">Users</h1>
|
||
<div className="page-sub">{USERS.length} active users · 7 roles</div>
|
||
</div>
|
||
<button className="btn maritime"><Icon name="plus" /> Add user</button>
|
||
</div>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead><tr><th>Employee ID</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
||
<tbody>
|
||
{USERS.map(u => (
|
||
<tr key={u.id}>
|
||
<td className="mono" style={{ fontSize: 12 }}>{u.emp}</td>
|
||
<td style={{ fontWeight: 500 }}>{u.name}</td>
|
||
<td className="muted">{u.email}</td>
|
||
<td><span className="role-badge">{u.role}</span></td>
|
||
<td><Badge className="closed" noDot>Active</Badge></td>
|
||
<td className="muted">{u.created}</td>
|
||
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
</>
|
||
);
|
||
|
||
/* ═══════════════════ CART ═══════════════════ */
|
||
const CartPage = ({ go }) => {
|
||
const [items, setItems] = useS([
|
||
{ id: 1, name: "Marine Bearing 6310-2RS", desc: "50×110×27mm sealed", vendor: "Mahalakshmi Marine Stores", price: 4250, qty: 24, gst: 18 },
|
||
{ id: 2, name: "Lube Oil Filter — Element", desc: "Cellulose, 10µ", vendor: "Coastline Engineering Co.", price: 1180, qty: 48, gst: 18 },
|
||
{ id: 3, name: "CO₂ Fire Extinguisher 9kg", desc: "Marine-grade, BIS certified", vendor: "Sealine Maritime Pvt Ltd", price: 8400, qty: 4, gst: 18 },
|
||
]);
|
||
const taxable = items.reduce((s, i) => s + i.price * i.qty, 0);
|
||
const gst = items.reduce((s, i) => s + i.price * i.qty * i.gst / 100, 0);
|
||
return (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Inventory", "Cart"]} />
|
||
<h1 className="page-title">Cart</h1>
|
||
<div className="page-sub">{items.length} items · saved locally to this device</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: 8 }}>
|
||
<button className="btn" onClick={() => setItems([])}>Clear cart</button>
|
||
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> Create PO from cart</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||
<Card>
|
||
{items.length === 0 ? <div className="empty-state">Cart is empty. Add items from the Item catalogue.</div> : (
|
||
<>
|
||
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 80px 24px", gap: 14, padding: "0 0 8px", fontSize: 11, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.06em", borderBottom: "1px solid var(--line)" }}>
|
||
<div>Item · Vendor</div>
|
||
<div style={{ textAlign: "right" }}>Unit price</div>
|
||
<div style={{ textAlign: "center" }}>Quantity</div>
|
||
<div style={{ textAlign: "right" }}>Subtotal</div>
|
||
<div></div>
|
||
</div>
|
||
{items.map(it => (
|
||
<div className="cart-line" key={it.id}>
|
||
<div>
|
||
<div style={{ fontWeight: 500 }}>{it.name}</div>
|
||
<div className="muted" style={{ fontSize: 11.5 }}>{it.desc} · <span style={{ color: "var(--primary-ink)" }}>{it.vendor}</span></div>
|
||
</div>
|
||
<div className="num mono">{inrFull(it.price)}</div>
|
||
<div style={{ display: "flex", justifyContent: "center" }}>
|
||
<input className="input mono" value={it.qty}
|
||
onChange={e => setItems(items.map(x => x.id === it.id ? { ...x, qty: Number(e.target.value) || 0 } : x))}
|
||
style={{ height: 28, width: 64, textAlign: "right", fontSize: 12 }} />
|
||
</div>
|
||
<div className="num mono">{inrFull(it.price * it.qty)}</div>
|
||
<button className="btn sm icon" onClick={() => setItems(items.filter(x => x.id !== it.id))}><Icon name="trash" size={11} /></button>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</Card>
|
||
|
||
<div>
|
||
<Card title="Order summary">
|
||
<dl className="kv" style={{ gridTemplateColumns: "1fr auto", fontSize: 12.5 }}>
|
||
<dt>Taxable</dt> <dd className="mono">{inrFull(taxable)}</dd>
|
||
<dt>GST</dt> <dd className="mono">{inrFull(gst)}</dd>
|
||
</dl>
|
||
<div className="divider" />
|
||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||
<span style={{ fontSize: 12, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Grand total</span>
|
||
<span className="mono" style={{ fontSize: 20, fontWeight: 500 }}>{inrFull(taxable + gst)}</span>
|
||
</div>
|
||
</Card>
|
||
|
||
<div style={{ height: 14 }} />
|
||
|
||
<Card title="Delivery site">
|
||
<div className="field">
|
||
<select className="select">
|
||
{SITES.map(s => <option key={s.id}>{s.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="muted" style={{ fontSize: 11.5, marginTop: 6 }}>
|
||
The selected site pre-fills as place of delivery on the new PO.
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/* ═══════════════════ IMPORT PO ═══════════════════ */
|
||
const ImportPOPage = ({ go }) => (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<Crumbs items={["Purchase Orders", "Import PO"]} />
|
||
<h1 className="page-title">Import PO from Excel</h1>
|
||
<div className="page-sub">Upload a file in Pelagia's standard PO template format</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: "grid", gridTemplateColumns: "1.6fr 1fr", gap: 16, marginBottom: 16 }}>
|
||
<Card title="1 · Upload file">
|
||
<div style={{ padding: 28, border: "1.5px dashed var(--line-2)", borderRadius: 8, textAlign: "center", color: "var(--muted)" }}>
|
||
<Icon name="upload" /> Drop .xlsx file here or <span style={{ color: "var(--primary-ink)" }}>browse</span>
|
||
<div className="faint" style={{ fontSize: 11.5, marginTop: 4 }}>Template downloadable from /docs/po-template.xlsx</div>
|
||
</div>
|
||
<div style={{ marginTop: 12, padding: "8px 10px", background: "var(--paper-2)", borderRadius: 6, fontSize: 12.5, display: "flex", alignItems: "center", gap: 10 }}>
|
||
<Icon name="check" />
|
||
<span><strong>Q-Mahalakshmi-2841.xlsx</strong> · 84 KB · parsed</span>
|
||
<span style={{ flex: 1 }} />
|
||
<span className="muted" style={{ fontSize: 11.5 }}>5 line items detected</span>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="2 · Pelagia metadata">
|
||
<div className="form-row" style={{ gap: 12 }}>
|
||
<div className="field">
|
||
<label className="field-label">Vessel <span className="req">*</span></label>
|
||
<select className="select"><option>MV Pelagia Voyager</option>{VESSELS.slice(1).map(v => <option key={v.id}>{v.name}</option>)}</select>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Account <span className="req">*</span></label>
|
||
<select className="select"><option>ENGINE — Engine Department</option>{ACCOUNTS.slice(0, 4).map(a => <option key={a.id}>{a.code} — {a.name}</option>)}</select>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<h2 className="section-title">3 · Extracted line items — review before saving</h2>
|
||
<Card flush>
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Item</th><th>Description</th><th className="num">Qty</th><th>Unit</th>
|
||
<th className="num">Unit price</th><th className="num">GST</th><th className="num">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ORDERS[0].items.concat([
|
||
{ name: "Marine Grease — EP2", desc: "Lithium complex, 18kg pail", qty: 6, unit: "ea", price: 4800, gst: 18 },
|
||
{ name: "O-Ring Set — FKM", desc: "Assorted sizes 40-pc kit", qty: 2, unit: "set", price: 2400, gst: 18 },
|
||
]).map((i, idx) => (
|
||
<tr key={idx}>
|
||
<td>{i.name}</td>
|
||
<td className="muted">{i.desc}</td>
|
||
<td className="num">{i.qty}</td>
|
||
<td className="muted">{i.unit}</td>
|
||
<td className="num mono">{inrFull(i.price)}</td>
|
||
<td className="num muted">{i.gst}%</td>
|
||
<td className="num mono">{inrFull(i.qty * i.price * (1 + i.gst / 100))}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
|
||
<div style={{ marginTop: 18, display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||
<button className="btn" onClick={() => go("my-orders")}>Cancel</button>
|
||
<button className="btn maritime">Save as Draft</button>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
Object.assign(window, { SitesPage, SiteDetailPage, VesselsPage, AccountsPage, UsersPage, CartPage, ImportPOPage });
|