dealer-scheme-advisor/src/screens/dealer/DealerHome.tsx
Yashas 134db9a06c Dealer Scheme Advisor console: dealer portal + supplier console over the Zino runtime
Vite + React + TS + Tailwind v4, runtime config.js and base-href contract
(same shape as HDFC-Loan-Desk). Dealer: home, place order with the scheme
engine live beside the form (query-kind record views over simulate()), orders
with the Advisor's advice and one-click confirm-as-advised, schemes with
progress to the next slab, incentives. Supplier: clusters + near-slab sweeps,
dealers and dealer 360, advisories, scheme catalogue with draft → Analyst
costing → approval, settlement runs with release/hold.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 16:38:05 +05:30

103 lines
6.8 KiB
TypeScript

import { Link } from 'react-router-dom';
import { ArrowRight, Sparkles } from 'lucide-react';
import { useQuery, useZino } from '../../api/provider';
import { RV } from '../../api/config';
import { Badge, Bar, Button, Card, Empty, ErrorNote, Kpi, Spinner } from '../../components/core';
import { RecCard, SimTable, useSimulate } from '../../components/widgets';
import { date, inr, mt, num, pct } from '../../format';
import { useDealer } from './useDealer';
export function DealerHome() {
const { client, portal } = useZino();
const cust = portal?.customer_no;
const d = useDealer(cust);
const sim = useSimulate(cust, null);
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 5, sortBy: 'created_at', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(cust), data_type: 'string' }] }), [cust], !!cust, 30000);
const upcoming = useQuery(() => client.rows(RV.UPCOMING, { params: { customer_no: cust } }), [cust], !!cust);
if (!portal) return <Spinner label="Finding your dealer account…" />;
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
if (d.loading && !d.data) return <Spinner />;
if (d.error) return <ErrorNote>{d.error}</ErrorNote>;
const s = d.data?.snapshot;
if (!s) return <Empty>No snapshot for {cust}.</Empty>;
const st = d.data?.standing;
const asOf = d.data?.as_of ?? '';
const monthEnd = asOf ? new Date(new Date(asOf).getFullYear(), new Date(asOf).getMonth() + 1, 0) : null;
const daysLeft = monthEnd && asOf ? Math.round((monthEnd.getTime() - new Date(asOf).getTime()) / 86400000) : null;
const earning = (sim.data ?? []).reduce((a, r) => a + num(r.payout_now), 0);
const top = recs.data?.[0];
return (
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-ink">{s.name}</h1>
<p className="text-sm text-muted">{s.customer_no} · {st?.cluster_name ?? s.cluster} cluster · {s.ao} · as of {date(asOf)}{daysLeft != null ? ` · ${daysLeft} days left in the month` : ''}</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{s.gold_star && <Badge tone="accent">Gold Star</Badge>}
{s.silver_star && <Badge tone="sky">Silver Star</Badge>}
{s.is_new_dealer && <Badge tone="emerald">New dealer</Badge>}
{s.is_inactive && <Badge tone="amber">Revival</Badge>}
{st?.is_weak_market && <Badge tone="slate">Weak-market cluster</Badge>}
{st?.is_border && <Badge tone="slate">Border cluster</Badge>}
{s.loyalty_tier && <Badge tone="slate">{s.loyalty_tier} tier</Badge>}
</div>
</div>
<Link to="/d/order/new"><Button kind="primary">Place an order <ArrowRight className="h-4 w-4" /></Button></Link>
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<div className="lift rounded-lg border border-line bg-panel px-4 py-3">
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Month to date</div>
<div className="tnum mt-1 text-xl font-semibold text-ink">{mt(s.mtd_mt)}</div>
<div className="mt-0.5 text-xs text-muted">target {mt(s.month_target_mt)} · {s.month_target_mt ? pct((s.mtd_mt / s.month_target_mt) * 100, 0) : '—'}</div>
<Bar value={s.mtd_mt} max={s.month_target_mt} className="mt-2" />
</div>
<Kpi label="Quarter to date" value={mt(s.qtd_mt)} sub={`target ${mt(s.quarter_target_mt)}`} />
<Kpi label="Earning this period" value={inr(earning)} sub="across every live scheme" tone="accent" />
<Kpi label="Open outstanding" value={inr(s.open_outstanding, { compact: true })} sub={s.overdue > 0 ? `${inr(s.overdue, { compact: true })} overdue` : 'nothing overdue'} tone={s.overdue > 0 ? 'rose' : undefined} />
<Kpi label="Cluster rank" value={st ? `#${st.rank} of ${st.peers}` : '—'} sub={st ? `${st.cluster_name} at ${pct(st.cluster_achievement_pct, 0)} of target` : ''} />
</div>
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
<div className="space-y-5 xl:col-span-2">
<Card title={<span className="inline-flex items-center gap-2"><Sparkles className="h-4 w-4 text-ai" />Advisor says</span>} subtitle="The latest nudges written for you. Numbers come from the scheme engine, not from the model.">
<div className="space-y-3 p-4">
{recs.loading && !recs.data && <Spinner />}
{recs.data && recs.data.length === 0 && <Empty>No advice yet — place an order and the Advisor reads it.</Empty>}
{top && <RecCard rec={top} />}
{(recs.data ?? []).slice(1, 3).map((r) => <RecCard key={String(r.rec_id)} rec={r} compact />)}
</div>
</Card>
<Card title="Your schemes right now" subtitle="Every live component you are eligible for, what it pays today, and how far the next slab is." actions={<Link to="/d/schemes" className="text-xs font-semibold text-brand">All schemes →</Link>}>
<SimTable rows={(sim.data ?? []).filter((r) => num(r.payout_now) > 0 || (r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0)).slice(0, 6)} hasHypo={false} loading={sim.loading} error={sim.error} />
</Card>
</div>
<div className="space-y-5">
<Card title="Coming up" subtitle="Schemes that open soon — worth timing an order for.">
<div className="divide-y divide-line">
{(upcoming.data ?? []).length === 0 && <Empty>Nothing opening in the next 45 days.</Empty>}
{(upcoming.data ?? []).map((u) => (
<div key={String(u.component_id)} className="px-4 py-3">
<div className="text-sm font-semibold text-ink">{String(u.scheme_name)}</div>
<div className="text-xs text-muted">opens {date(u.validity_from)} · in {String(u.starts_in_days)} days · to {date(u.validity_to)}</div>
<p className="mt-1 line-clamp-3 text-xs text-faint whitespace-pre-line">{String(u.circular_text ?? '').split('\n').slice(2).join(' ')}</p>
</div>
))}
</div>
</Card>
<Card title="Open orders">
<div className="divide-y divide-line">
{s.open_orders.length === 0 && <Empty>No open orders in the estate.</Empty>}
{s.open_orders.map((o) => (
<div key={o.so_no} className="flex items-center justify-between px-4 py-2.5 text-sm"><div><div className="font-semibold text-ink">{o.so_no}</div><div className="text-xs text-faint">for {date(o.requested)}</div></div><div className="text-right"><div className="tnum text-ink">{mt(o.qty_mt)}</div><Badge tone={o.status === 'DRAFT' ? 'slate' : 'sky'}>{o.status}</Badge></div></div>
))}
</div>
</Card>
</div>
</div>
</div>
);
}