bunker-enquiry-desk/src/screens/Inbox.tsx
Yashas a42216aa12 Bunker Enquiry Desk portal: enquiry inbox, enquiry detail, Ops and Trader actions
Ops and Trader portal for the Adani bunker enquiry demo (dev org 900000130 /
app 900000230), standing in for the Adobe enquiry portal. Spectrum-style
layout, Source Sans 3, light and dark tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:17:22 +05:30

102 lines
5.6 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search } from 'lucide-react';
import { useQuery, useZino } from '../api/provider';
import { RV_ENQUIRIES, STATES } from '../api/config';
import type { Enquiry } from '../api/types';
import { Badge, Empty, ErrorNote, StateBadge, qtyText } from '../components/ui';
/** Which enquiries are waiting on THIS user. */
function needsMe(e: Enquiry, has: (...r: string[]) => boolean): boolean {
if (e.current_state_name === 'Awaiting Ops Input') return has('bunker_ops', 'trading_head');
if (e.current_state_name === 'Awaiting Offer') return has('bunker_trader', 'trading_head');
if (e.current_state_name === 'Offer Sent' && e.customer_decision === 'query') return has('bunker_trader', 'trading_head');
if (e.current_state_name === 'Confirmed') return has('bunker_ops', 'trading_head');
return false;
}
export function Inbox() {
const { client, has } = useZino();
const nav = useNavigate();
const [state, setState] = useState<string>('Mine');
const [q, setQ] = useState('');
const { data, error, loading } = useQuery(() => client.page(RV_ENQUIRIES, { limit: 200, sortBy: 'instance_id', sortDir: 'desc' }), [], 5000);
const rows = (data?.rows ?? []) as unknown as Enquiry[];
const counts = useMemo(() => {
const c: Record<string, number> = { Mine: 0, All: rows.length };
for (const s of STATES) c[s] = 0;
for (const r of rows) { c[r.current_state_name] = (c[r.current_state_name] ?? 0) + 1; if (needsMe(r, has)) c.Mine++; }
return c;
}, [rows, has]);
const shown = rows.filter((r) => {
if (state === 'Mine' && !needsMe(r, has)) return false;
if (state !== 'Mine' && state !== 'All' && r.current_state_name !== state) return false;
if (!q.trim()) return true;
const hay = [r.enquiry_ref, r.vessel_name, r.imo_number, r.port, r.product, r.buyer_account, r.customer_contact].join(' ').toLowerCase();
return hay.includes(q.trim().toLowerCase());
});
const tabs = ['Mine', 'All', ...STATES];
return (
<div className="mx-auto max-w-7xl space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold">Enquiries</h1>
<p className="text-sm text-muted">Every bunker enquiry received on WhatsApp and email, from first message to confirmed stem.</p>
</div>
<label className="relative w-full sm:w-72">
<Search size={16} className="pointer-events-none absolute left-2.5 top-2.5 text-muted" />
<input className="field-input pl-8" placeholder="Search ref, vessel, IMO, buyer" value={q} onChange={(e) => setQ(e.target.value)} />
</label>
</div>
<div className="flex gap-1 overflow-x-auto border-b border-line">
{tabs.map((t) => (
<button key={t} onClick={() => setState(t)}
className={`-mb-px flex shrink-0 items-center gap-2 border-b-2 px-3 py-2 text-sm font-semibold ${state === t ? 'border-accent text-ink' : 'border-transparent text-muted hover:text-ink'}`}>
{t === 'Mine' ? 'Needs my action' : t}
<span className={`rounded-full px-1.5 text-xs ${state === t ? 'bg-accent text-white' : 'bg-surface-2'}`}>{counts[t] ?? 0}</span>
</button>
))}
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="card overflow-x-auto">
<table className="w-full min-w-[900px] text-sm">
<thead>
<tr className="border-b border-line bg-surface-2 text-left text-xs uppercase tracking-wide text-muted">
<th className="px-3 py-2">Reference</th><th className="px-3 py-2">Vessel</th><th className="px-3 py-2">Port</th>
<th className="px-3 py-2">ETA / Laycan</th><th className="px-3 py-2">Product</th><th className="px-3 py-2">Quantity</th>
<th className="px-3 py-2">Buyer</th><th className="px-3 py-2">Touch points</th><th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody>
{shown.map((r) => (
<tr key={r.instance_id} onClick={() => nav(`/enquiries/${r.instance_id}`)} className="cursor-pointer border-b border-line last:border-0 hover:bg-surface-2">
<td className="px-3 py-2.5 font-semibold text-accent">{r.enquiry_ref ?? `#${r.instance_id}`}</td>
<td className="px-3 py-2.5">{r.vessel_name ?? <span className="text-muted">-</span>}{r.imo_number && <div className="text-xs text-muted">IMO {r.imo_number}</div>}</td>
<td className="px-3 py-2.5">{r.port ?? '-'}</td>
<td className="px-3 py-2.5">{r.eta_laycan ?? '-'}</td>
<td className="px-3 py-2.5">{r.product ?? '-'}</td>
<td className="px-3 py-2.5 tabular-nums">{qtyText(r.min_qty_mt, r.max_qty_mt) || '-'}</td>
<td className="px-3 py-2.5">{r.buyer_account ?? '-'}</td>
<td className="px-3 py-2.5 tabular-nums">{r.touch_points_found ?? 0}/8</td>
<td className="px-3 py-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<StateBadge state={r.current_state_name} />
{r.current_state_name === 'Offer Sent' && r.customer_decision === 'query' && <Badge tone="notice">Customer replied</Badge>}
</div>
</td>
</tr>
))}
</tbody>
</table>
{!loading && shown.length === 0 && <Empty>{state === 'Mine' ? 'Nothing is waiting on you.' : 'No enquiries here.'}</Empty>}
{loading && rows.length === 0 && <Empty>Loading…</Empty>}
</div>
</div>
);
}