- 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>
186 lines
13 KiB
TypeScript
186 lines
13 KiB
TypeScript
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' }) : ''; }
|