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>
102 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|