dealer-scheme-advisor/src/screens/dealer/DealerHome.tsx
Yashas d709555d28 Dealer app goes mobile-first: Today cards with one-tap orders, condensed order page, bottom tabs
- Today: three chips (month, earning, dues) + the engine's ranked action cards
  (dsa-rv-recommend); one button per card places a CONFIRMED order through
  dsa-act-place-advised (no AI turn) from a bottom sheet; Why folds the movers.
- Order: product chips, a quantity stepper, the big 'this order earns' number
  with the top three movers; Place order (immediate) or Ask the Advisor (AI).
- Orders / detail, Schemes, Earnings condensed for a phone; Shell gains a
  mobile mode with a bottom tab bar; supplier console unchanged.

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

186 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, CalendarDays, CheckCircle2, ChevronDown, IndianRupee, Package, Sparkles } from 'lucide-react';
import { useQuery, useZino } from '../../api/provider';
import { RV } from '../../api/config';
import type { RecRow } from '../../api/types';
import { Bar, Button, Chip, ErrorNote, Input, Label, Select, Sheet, Spinner } from '../../components/core';
import { useSimulate } from '../../components/widgets';
import { date, inr, mt, num, parseJson } from '../../format';
import { placeOneTap, plusDays, useDealer, useDealerRow, useRecommend } from './useDealer';
import type { Opt } from './useDealer';
const KIND_TITLE: Record<string, string> = { TOP_UP: 'Top up', COLLECTION: 'Clear dues', SWITCH_TO_PP: 'Go premium', ROUTE_SHIP_TO: 'Change ship-to', BOOK_IN_WINDOW: 'Book later', NOTHING_TO_DO: 'All good' };
/** Today: three numbers, then the cards. Each card is one action and one button. */
export function Today() {
const { client, portal } = useZino();
const navigate = useNavigate();
const cust = portal?.customer_no;
const d = useDealer(cust);
const row = useDealerRow(cust);
const recs = useRecommend(cust);
const sim = useSimulate(cust, null);
const upcoming = useQuery(() => client.rows(RV.UPCOMING, { params: { customer_no: cust } }), [cust], !!cust);
const [act, setAct] = useState<RecRow | null>(null);
const [reminded, setReminded] = useState<Record<string, boolean>>(() => { try { return JSON.parse(localStorage.getItem('meru-reminders') ?? '{}'); } catch { return {}; } });
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 <ErrorNote>No snapshot for {cust}.</ErrorNote>;
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 cards = (recs.data ?? []).filter((r) => r.kind !== 'NOTHING_TO_DO');
const nothing = (recs.data ?? []).find((r) => r.kind === 'NOTHING_TO_DO');
function remind(r: RecRow) {
const next = { ...reminded, [r.kind + (r.book_on ?? '')]: true };
setReminded(next);
try { localStorage.setItem('meru-reminders', JSON.stringify(next)); } catch { /* fine */ }
}
return (
<div className="space-y-4">
<div>
<h1 className="text-lg font-semibold text-ink">{greeting()}, {firstName(s.name)}</h1>
<p className="text-[13px] text-muted">{daysLeft != null ? `${daysLeft} days left in ${monthName(asOf)}` : date(asOf)} · {s.cluster} cluster</p>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="lift min-w-0 rounded-lg border border-line bg-panel px-3 py-2.5">
<div className="truncate text-[10px] font-semibold tracking-wide text-faint uppercase">This month</div>
<div className="tnum mt-0.5 text-[17px] leading-tight font-semibold text-ink">{mt(s.mtd_mt, 0)}</div>
<div className="mt-0.5 truncate text-[11px] text-muted">of {mt(s.month_target_mt, 0)}</div>
<Bar value={s.mtd_mt} max={s.month_target_mt} className="mt-1.5" />
</div>
<Chip label="Earning" value={inr(earning, { compact: earning >= 100000 })} sub="this period" tone="accent" />
<Chip label="Dues" value={inr(s.open_outstanding, { compact: true })} sub={s.overdue > 0 ? `${inr(s.overdue, { compact: true })} overdue` : 'none overdue'} tone={s.overdue > 0 ? 'rose' : undefined} />
</div>
<section>
<h2 className="mb-2 flex items-center gap-1.5 text-[13px] font-semibold tracking-wide text-muted uppercase"><Sparkles className="h-3.5 w-3.5 text-ai" />Do this today</h2>
{recs.loading && !recs.data && <Spinner label="Checking your schemes…" />}
{recs.error && <ErrorNote>{recs.error}</ErrorNote>}
<div className="space-y-3">
{cards.map((r) => (
<ActionCard key={r.rank} rec={r} reminded={!!reminded[r.kind + (r.book_on ?? '')]}
onAct={() => { if (r.action === 'ORDER' || r.action === 'ORDER_PP') setAct(r); else if (r.action === 'PAY') navigate('/d/earnings#dues'); else remind(r); }} />
))}
{recs.data && cards.length === 0 && (
<div className="rounded-lg border border-good-edge bg-good-soft p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-good"><CheckCircle2 className="h-4 w-4" />{nothing?.headline ?? 'You are on the best slab this month'}</div>
<p className="mt-1 text-[13px] text-muted">{nothing?.detail ?? 'No extra order or payment earns more right now.'}</p>
</div>
)}
</div>
</section>
{(upcoming.data ?? []).length > 0 && (
<section>
<h2 className="mb-2 text-[13px] font-semibold tracking-wide text-muted uppercase">Coming up</h2>
<ul className="divide-y divide-line rounded-lg border border-line bg-panel">
{(upcoming.data ?? []).slice(0, 3).map((u) => (
<li key={String(u.component_id)} className="flex items-center gap-3 px-3 py-2.5">
<CalendarDays className="h-4 w-4 shrink-0 text-faint" />
<div className="min-w-0"><div className="truncate text-[13px] font-semibold text-ink">{String(u.scheme_name)}</div><div className="text-[11px] text-faint">{date(u.validity_from)} – {date(u.validity_to)} · in {String(u.starts_in_days)} days</div></div>
</li>
))}
</ul>
</section>
)}
{act && (
<OneTapSheet rec={act} onClose={() => setAct(null)} cust={cust} dealerName={s.name} asOf={asOf} row={row.data}
onPlaced={(id) => { setAct(null); navigate(`/d/orders/${id}?placed=1`); }} />
)}
</div>
);
}
function ActionCard({ rec, onAct, reminded }: { rec: RecRow; onAct: () => void; reminded: boolean }) {
const [why, setWhy] = useState(false);
const inc = num(rec.incremental);
const label = rec.action === 'ORDER' ? `Order ${mt(rec.delta_mt, 0)}` : rec.action === 'ORDER_PP' ? `Order ${mt(rec.delta_pp_mt, 0)} premium` : rec.action === 'PAY' ? 'See dues' : reminded ? 'Reminder set' : 'Remind me';
const Icon = rec.action === 'PAY' ? IndianRupee : rec.action === 'REMIND' ? Bell : Package;
const movers = String(rec.movers ?? '').split(', ').filter(Boolean);
return (
<div className={`lift rounded-xl border p-4 ${rec.rank === 1 ? 'border-accent-edge bg-accent-soft' : 'border-line bg-panel'}`}>
<div className="flex items-center justify-between gap-3">
<span className="shrink-0 text-[11px] font-semibold tracking-wide text-muted uppercase">{KIND_TITLE[rec.kind] ?? rec.kind}{rec.deadline ? ` · by ${shortDate(rec.deadline)}` : ''}</span>
<span className="truncate text-[11px] text-faint">{rec.scheme_name ? String(rec.scheme_name).replace(/\s*–\s*AO-?.*$/, '') : ''}</span>
</div>
<div className="mt-1.5 flex items-end justify-between gap-3">
<p className="text-[15px] leading-snug font-semibold text-ink">{rec.headline}</p>
<div className="tnum hero-num shrink-0 font-semibold text-accent">{inr(inc, { sign: true, compact: inc >= 100000 })}</div>
</div>
<div className="mt-3 flex items-center gap-2">
<Button kind={rec.rank === 1 ? 'primary' : 'secondary'} size="lg" className="flex-1" onClick={onAct} disabled={reminded}><Icon className="h-5 w-5" />{label}</Button>
<button onClick={() => setWhy((w) => !w)} className="inline-flex items-center gap-1 rounded-md px-2 py-2 text-[13px] font-semibold text-muted hover:bg-panel2" aria-expanded={why}>Why<ChevronDown className={`h-4 w-4 transition-transform ${why ? 'rotate-180' : ''}`} /></button>
</div>
{why && (
<ul className="mt-3 space-y-1 border-t border-line pt-3 text-[13px] text-muted">
{movers.length === 0 && <li>{rec.detail}</li>}
{movers.slice(0, 5).map((m) => { const i = m.lastIndexOf(' '); return <li key={m} className="flex justify-between gap-3"><span>{m.slice(0, i)}</span><span className="tnum font-semibold text-ink">{m.slice(i + 1).replace(/^\+/, '+₹').replace(/^−/, '−₹')}</span></li>; })}
<li className="pt-1 text-[11px] text-faint">Now {inr(rec.payout_now)} → {inr(rec.payout_if)} if you act. Numbers from the scheme engine, as of today.</li>
</ul>
)}
</div>
);
}
/** The confirm step: what, where, when — one big button. Lands as a CONFIRMED order in the estate. */
export function OneTapSheet({ rec, onClose, cust, dealerName, asOf, row, onPlaced }: {
rec: RecRow; onClose: () => void; cust: string; dealerName: string; asOf: string; row: Record<string, unknown> | null; onPlaced: (id: number | string) => void;
}) {
const { client } = useZino();
const materials = useMemo(() => parseJson<Opt[]>(row?.materials_json, []), [row]);
const shipTos = useMemo(() => parseJson<Opt[]>(row?.ship_tos_json, []), [row]);
const premium = rec.action === 'ORDER_PP';
const qty = num(premium ? rec.delta_pp_mt : rec.delta_mt);
const defaultMat = premium ? (materials.find((m) => m.premium)?.code ?? '') : String(row?.default_material ?? materials[0]?.code ?? '');
const [material, setMaterial] = useState(defaultMat);
const [shipTo, setShipTo] = useState(rec.ship_to || String(row?.default_ship_to ?? shipTos[0]?.code ?? ''));
const [when, setWhen] = useState(plusDays(asOf, 3));
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const mat = material || defaultMat;
const plant = String(row?.default_plant ?? '');
const matName = materials.find((m) => m.code === mat)?.name ?? mat;
async function place() {
if (!mat || !shipTo || qty <= 0) { setErr('Pick a material and a ship-to'); return; }
setBusy(true); setErr(null);
try {
const res = await placeOneTap(client, {
customer_no: cust, dealer_name: dealerName, ship_to_code: shipTo, plant_code: plant, material_code: mat, qty_mt: qty, requested_date: when, as_of: asOf,
advice_kind: rec.kind, advice_headline: rec.headline, incremental_payout: num(rec.incremental), suggested_scheme: rec.scheme_name, advice_deadline: rec.deadline ? String(rec.deadline).slice(0, 10) : null, advice_accepted: true,
});
onPlaced(res.instance_id ?? '');
} catch (e) { setErr((e as { message?: string })?.message ?? 'Could not place the order'); } finally { setBusy(false); }
}
return (
<Sheet open onClose={onClose} title={`Order ${mt(qty, 0)}${premium ? ' premium' : ''}`}>
<div className="space-y-3">
{err && <ErrorNote>{err}</ErrorNote>}
<div className="rounded-lg border border-accent-edge bg-accent-soft px-3 py-2.5 text-[13px] text-ink"><b>{inr(num(rec.incremental), { sign: true })}</b> more this period · {rec.headline}</div>
<div><Label>Material</Label>{materials.length > 1 ? <Select value={mat} onChange={setMaterial} options={materials.filter((m) => !premium || m.premium).map((m) => ({ value: m.code, label: `${m.name}${m.premium ? ' · premium' : ''}` }))} /> : <div className="text-sm text-ink">{matName}</div>}</div>
<div><Label>Deliver to</Label>{shipTos.length > 1 ? <Select value={shipTo} onChange={setShipTo} options={shipTos.map((s) => ({ value: s.code, label: s.name }))} /> : <div className="text-sm text-ink">{shipTos[0]?.name ?? shipTo}</div>}</div>
<div><Label>Delivery date</Label><Input type="date" value={when} onChange={setWhen} /></div>
<Button kind="primary" size="lg" className="w-full" onClick={place} busy={busy}><Package className="h-5 w-5" />Place order · {mt(qty, 0)} {matName}</Button>
<p className="text-center text-[11px] text-faint">Confirmed straight away and sent to Meru as sales order.</p>
</div>
</Sheet>
);
}
function shortDate(v: unknown): string { const d = new Date(String(v)); return Number.isNaN(d.getTime()) ? String(v ?? '') : d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }); }
function greeting(): string { const h = new Date().getHours(); return h < 12 ? 'Good morning' : h < 17 ? 'Good afternoon' : 'Good evening'; }
function firstName(n: string): string { return (n ?? '').replace(/\b(traders|agencies|enterprises|cement|stores|and sons|& sons)\b.*$/i, '').trim().split(' ')[0] || n; }
function monthName(iso: string): string { return iso ? new Date(iso).toLocaleDateString('en-IN', { month: 'long' }) : ''; }