diff --git a/src/App.tsx b/src/App.tsx index fb3ab63..6aacbcb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,28 @@ import { Navigate, Route, Routes } from 'react-router-dom'; +import { ClipboardList, Home, IndianRupee, PlusCircle, Tag } from 'lucide-react'; import { useZino } from './api/provider'; import { Shell } from './layout/Shell'; import type { NavItem } from './layout/Shell'; import { Login } from './pages/Login'; import { Spinner } from './components/core'; -import { DealerHome } from './screens/dealer/DealerHome'; +import { Today } from './screens/dealer/DealerHome'; import { NewOrder } from './screens/dealer/NewOrder'; import { DealerOrders, OrderDetail } from './screens/dealer/Orders'; import { DealerSchemes } from './screens/dealer/Schemes'; -import { DealerIncentives } from './screens/dealer/Incentives'; +import { Earnings } from './screens/dealer/Incentives'; import { Overview } from './screens/supplier/Overview'; import { Dealers, Dealer360 } from './screens/supplier/Dealers'; import { Catalogue } from './screens/supplier/Catalogue'; import { Runs } from './screens/supplier/Runs'; import { Advisories, AdvisoryDetail } from './screens/supplier/Advisories'; +/** The dealer app is a phone: five tabs, one thumb. */ const DEALER_NAV: NavItem[] = [ - { to: '/d', label: 'Home', hint: 'This month, at a glance', end: true }, - { to: '/d/order/new', label: 'Place order', hint: 'With the Advisor beside you' }, - { to: '/d/orders', label: 'My orders', hint: 'Drafts, advice, confirmations' }, - { to: '/d/schemes', label: 'My schemes', hint: 'What pays, and what is close' }, - { to: '/d/incentives', label: 'Incentives', hint: 'Tracking and credit notes' }, + { to: '/d', label: 'Today', hint: 'What to do today', end: true, icon: Home }, + { to: '/d/order', label: 'Order', hint: 'Place an order', icon: PlusCircle }, + { to: '/d/orders', label: 'Orders', hint: 'My orders', icon: ClipboardList }, + { to: '/d/schemes', label: 'Schemes', hint: 'What pays', icon: Tag }, + { to: '/d/earnings', label: 'Earnings', hint: 'Paid and accruing', icon: IndianRupee }, ]; const SUPPLIER_NAV: NavItem[] = [ { to: '/s', label: 'Territory', hint: 'Clusters and the near-slab sweep', end: true }, @@ -42,13 +44,15 @@ export function App() { return ( {isDealer && ( - }> - } /> - } /> + }> + } /> + } /> + } /> } /> } /> } /> - } /> + } /> + } /> )} {(isSupplier || !isDealer) && ( diff --git a/src/api/config.ts b/src/api/config.ts index 3be57ad..16dadef 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -13,6 +13,8 @@ export const WF = { export const ACT = { PLACE: 'dsa-act-place', + /** One tap from Today: the engine's card becomes a CONFIRMED order, no AI turn. */ + PLACE_ADVISED: 'dsa-act-place-advised', CONFIRM: 'dsa-act-confirm', CANCEL: 'dsa-act-cancel', DISPATCH: 'dsa-act-dispatch', @@ -49,6 +51,8 @@ export const RV = { SNAPSHOT: 'dsa-rv-dealer_snapshot', HISTORY: 'dsa-rv-monthly_history', UPCOMING: 'dsa-rv-upcoming_schemes', + /** scheme_erp.recommend(): the ranked shortlist of actions a dealer can take this period. */ + RECOMMEND: 'dsa-rv-recommend', } as const; export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet' | 'accent'; diff --git a/src/api/types.ts b/src/api/types.ts index a7657e2..170a5fb 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -45,3 +45,12 @@ export interface Snapshot { } export interface Standing { cluster: string; cluster_name: string; is_weak_market: boolean; is_border: boolean; peers: number; rank: number; cluster_mtd_mt: number; cluster_target_mt: number; cluster_achievement_pct: number | null; top3: Array<{ name: string; mtd_mt: number }>; } export interface PortalUser { email: string; persona: 'DEALER' | 'ASM' | 'RSO' | 'HO' | 'AI'; customer_no: string | null; rso_code: string | null; ao_code: string | null; display_name: string; } + +/** One recommend() card: an action the dealer can take, already simulated. */ +export interface RecRow { + rank: number; kind: 'TOP_UP' | 'COLLECTION' | 'SWITCH_TO_PP' | 'ROUTE_SHIP_TO' | 'BOOK_IN_WINDOW' | 'NOTHING_TO_DO' | string; + headline: string; detail: string | null; delta_mt: string | number | null; delta_pp_mt: string | number | null; clear_outstanding_amt: string | number | null; + ship_to: string | null; book_on: string | null; payout_now: string | number; payout_if: string | number; incremental: string | number; deadline: string | null; + scheme_name: string | null; component_name: string | null; movers: string | null; hypo: string | null; + action: 'ORDER' | 'ORDER_PP' | 'PAY' | 'REMIND' | 'NONE' | string; +} diff --git a/src/components/core/index.tsx b/src/components/core/index.tsx index 3fb6717..69c193b 100644 --- a/src/components/core/index.tsx +++ b/src/components/core/index.tsx @@ -32,8 +32,8 @@ const BUTTON_KIND: Record = { accent: 'bg-accent-soft text-accent ring-1 ring-inset ring-accent-edge hover:bg-panel3', }; export function Button({ children, onClick, kind = 'secondary', disabled = false, busy = false, type = 'button', className = '', title, size = 'md' }: - { children: ReactNode; onClick?: () => void; kind?: ButtonKind; disabled?: boolean; busy?: boolean; type?: 'button' | 'submit'; className?: string; title?: string; size?: 'sm' | 'md' }) { - const pad = size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3.5 py-2 text-sm'; + { children: ReactNode; onClick?: () => void; kind?: ButtonKind; disabled?: boolean; busy?: boolean; type?: 'button' | 'submit'; className?: string; title?: string; size?: 'sm' | 'md' | 'lg' }) { + const pad = size === 'sm' ? 'px-2.5 py-1.5 text-xs' : size === 'lg' ? 'px-5 py-3.5 text-base' : 'px-3.5 py-2 text-sm'; return ( @@ -141,3 +141,19 @@ export function Bar({ value, max, className = '' }: { value: number; max: number const p = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0; return
; } + +/** A phone bottom sheet (centered dialog on wide screens). The one place the dealer confirms an action. */ +export function Sheet({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: ReactNode; children: ReactNode }) { + return {children}; +} + +/** A compact number with a caption, for a row of three on a phone. */ +export function Chip({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone?: Tone }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/src/components/widgets.tsx b/src/components/widgets.tsx index 651f292..2836ed4 100644 --- a/src/components/widgets.tsx +++ b/src/components/widgets.tsx @@ -177,14 +177,14 @@ export function SimTable({ rows, hasHypo, loading, error }: { rows: SimRow[] | n // --------------------------------------------------------------------------- // What happened on an instance: the audit trail + the AI's own reasoning. // --------------------------------------------------------------------------- -export function InstanceTrace({ instanceId }: { instanceId: number | string }) { +export function InstanceTrace({ instanceId, bare = false }: { instanceId: number | string; bare?: boolean }) { const { client } = useZino(); const audit = useQuery(() => client.audit(instanceId), [instanceId], true, 15000); // The AI's rationale is on the audit row (ai_reasoning) and in the fields it submitted; // /monitor/* is Studio-scoped on this build (403 for app users), so it is not called. const ai = { data: [] as AiDecision[] }; - return ( - + const body = ( + <> {audit.loading && !audit.data && } {audit.error &&
{audit.error}
}
    @@ -216,6 +216,8 @@ export function InstanceTrace({ instanceId }: { instanceId: number | string }) { })}
{audit.data && audit.data.length === 0 && No steps recorded yet.} -
+ ); + if (bare) return body; + return {body}; } diff --git a/src/layout/Shell.tsx b/src/layout/Shell.tsx index ea729fd..e0d9bd5 100644 --- a/src/layout/Shell.tsx +++ b/src/layout/Shell.tsx @@ -1,16 +1,53 @@ import { NavLink, Outlet } from 'react-router-dom'; +import type { LucideIcon } from 'lucide-react'; import { LogOut } from 'lucide-react'; import { useZino } from '../api/provider'; import { prettyRole } from '../api/config'; -import { AppLogo } from '../components/Logo'; +import { AppLogo, MeruMark } from '../components/Logo'; import { ThemeToggle } from '../components/ThemeToggle'; -export interface NavItem { to: string; label: string; hint: string; end?: boolean; } +export interface NavItem { to: string; label: string; hint: string; end?: boolean; icon?: LucideIcon; } /** The chrome. Nothing here is role-gated beyond choosing which nav to show: - * the workflow re-checks RBAC on every submission. */ -export function Shell({ items, sub }: { items: NavItem[]; sub: string }) { + * the workflow re-checks RBAC on every submission. + * `mobile` is the dealer app: a phone-width column with a bottom tab bar. */ +export function Shell({ items, sub, mobile = false }: { items: NavItem[]; sub: string; mobile?: boolean }) { const { user, portal, logout } = useZino(); + if (mobile) { + return ( +
+
+
+ +
+
{portal?.display_name || user?.name || 'Meru Cement'}
+
{portal?.customer_no ? `${portal.customer_no} · ` : ''}{sub}
+
+
+ + +
+
+
+
+ +
+ ); + } return (
diff --git a/src/screens/dealer/DealerHome.tsx b/src/screens/dealer/DealerHome.tsx index 35c533c..b5044c5 100644 --- a/src/screens/dealer/DealerHome.tsx +++ b/src/screens/dealer/DealerHome.tsx @@ -1,102 +1,185 @@ -import { Link } from 'react-router-dom'; -import { ArrowRight, Sparkles } from 'lucide-react'; +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 { 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'; +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'; -export function DealerHome() { +const KIND_TITLE: Record = { 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 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); + const [act, setAct] = useState(null); + const [reminded, setReminded] = useState>(() => { try { return JSON.parse(localStorage.getItem('meru-reminders') ?? '{}'); } catch { return {}; } }); if (!portal) return ; if (!cust) return This login is not mapped to a dealer.; if (d.loading && !d.data) return ; if (d.error) return {d.error}; const s = d.data?.snapshot; - if (!s) return No snapshot for {cust}.; - const st = d.data?.standing; + if (!s) return No snapshot for {cust}.; 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]; + 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 ( -
-
-
-

{s.name}

-

{s.customer_no} · {st?.cluster_name ?? s.cluster} cluster · {s.ao} · as of {date(asOf)}{daysLeft != null ? ` · ${daysLeft} days left in the month` : ''}

-
- {s.gold_star && Gold Star} - {s.silver_star && Silver Star} - {s.is_new_dealer && New dealer} - {s.is_inactive && Revival} - {st?.is_weak_market && Weak-market cluster} - {st?.is_border && Border cluster} - {s.loyalty_tier && {s.loyalty_tier} tier} -
-
- +
+
+

{greeting()}, {firstName(s.name)}

+

{daysLeft != null ? `${daysLeft} days left in ${monthName(asOf)}` : date(asOf)} · {s.cluster} cluster

-
-
-
Month to date
-
{mt(s.mtd_mt)}
-
target {mt(s.month_target_mt)} · {s.month_target_mt ? pct((s.mtd_mt / s.month_target_mt) * 100, 0) : '—'}
- +
+
+
This month
+
{mt(s.mtd_mt, 0)}
+
of {mt(s.month_target_mt, 0)}
+
- - - 0 ? `${inr(s.overdue, { compact: true })} overdue` : 'nothing overdue'} tone={s.overdue > 0 ? 'rose' : undefined} /> - + = 100000 })} sub="this period" tone="accent" /> + 0 ? `${inr(s.overdue, { compact: true })} overdue` : 'none overdue'} tone={s.overdue > 0 ? 'rose' : undefined} />
-
-
- Advisor says} subtitle="The latest nudges written for you. Numbers come from the scheme engine, not from the model."> -
- {recs.loading && !recs.data && } - {recs.data && recs.data.length === 0 && No advice yet — place an order and the Advisor reads it.} - {top && } - {(recs.data ?? []).slice(1, 3).map((r) => )} +
+

Do this today

+ {recs.loading && !recs.data && } + {recs.error && {recs.error}} +
+ {cards.map((r) => ( + { 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 && ( +
+
{nothing?.headline ?? 'You are on the best slab this month'}
+

{nothing?.detail ?? 'No extra order or payment earns more right now.'}

- - All schemes →}> - 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} /> - + )}
-
- -
- {(upcoming.data ?? []).length === 0 && Nothing opening in the next 45 days.} - {(upcoming.data ?? []).map((u) => ( -
-
{String(u.scheme_name)}
-
opens {date(u.validity_from)} · in {String(u.starts_in_days)} days · to {date(u.validity_to)}
-

{String(u.circular_text ?? '').split('\n').slice(2).join(' ')}

-
- ))} -
-
- -
- {s.open_orders.length === 0 && No open orders in the estate.} - {s.open_orders.map((o) => ( -
{o.so_no}
for {date(o.requested)}
{mt(o.qty_mt)}
{o.status}
- ))} -
-
-
-
+ + + {(upcoming.data ?? []).length > 0 && ( +
+

Coming up

+
    + {(upcoming.data ?? []).slice(0, 3).map((u) => ( +
  • + +
    {String(u.scheme_name)}
    {date(u.validity_from)} – {date(u.validity_to)} · in {String(u.starts_in_days)} days
    +
  • + ))} +
+
+ )} + + {act && ( + setAct(null)} cust={cust} dealerName={s.name} asOf={asOf} row={row.data} + onPlaced={(id) => { setAct(null); navigate(`/d/orders/${id}?placed=1`); }} /> + )}
); } + +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 ( +
+
+ {KIND_TITLE[rec.kind] ?? rec.kind}{rec.deadline ? ` · by ${shortDate(rec.deadline)}` : ''} + {rec.scheme_name ? String(rec.scheme_name).replace(/\s*–\s*AO-?.*$/, '') : ''} +
+
+

{rec.headline}

+
{inr(inc, { sign: true, compact: inc >= 100000 })}
+
+
+ + +
+ {why && ( +
    + {movers.length === 0 &&
  • {rec.detail}
  • } + {movers.slice(0, 5).map((m) => { const i = m.lastIndexOf(' '); return
  • {m.slice(0, i)}{m.slice(i + 1).replace(/^\+/, '+₹').replace(/^−/, '−₹')}
  • ; })} +
  • Now {inr(rec.payout_now)} → {inr(rec.payout_if)} if you act. Numbers from the scheme engine, as of today.
  • +
+ )} +
+ ); +} + +/** 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 | null; onPlaced: (id: number | string) => void; +}) { + const { client } = useZino(); + const materials = useMemo(() => parseJson(row?.materials_json, []), [row]); + const shipTos = useMemo(() => parseJson(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(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 ( + +
+ {err && {err}} +
{inr(num(rec.incremental), { sign: true })} more this period · {rec.headline}
+
{materials.length > 1 ? ({ value: s.code, label: s.name }))} /> :
{shipTos[0]?.name ?? shipTo}
}
+
+ +

Confirmed straight away and sent to Meru as sales order.

+
+
+ ); +} + +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' }) : ''; } diff --git a/src/screens/dealer/Incentives.tsx b/src/screens/dealer/Incentives.tsx index d212b5e..4ed5bcf 100644 --- a/src/screens/dealer/Incentives.tsx +++ b/src/screens/dealer/Incentives.tsx @@ -1,40 +1,79 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { useQuery, useZino } from '../../api/provider'; import { RV } from '../../api/config'; -import { Badge, Card, ErrorNote, Kpi } from '../../components/core'; -import { DataTable } from '../../components/widgets'; +import type { Row } from '../../api/types'; +import { Badge, Chip, ErrorNote, Spinner } from '../../components/core'; import { date, inr, mt, num } from '../../format'; +import { useDealer } from './useDealer'; -export function DealerIncentives() { +/** Earnings: what was paid, what is accruing, and the dues that gate a collection scheme. */ +export function Earnings() { const { client, portal } = useZino(); + const { hash } = useLocation(); const cust = portal?.customer_no; + const d = useDealer(cust); const q = useQuery(() => client.rows(RV.TRACKING, { limit: 200, sortBy: 'period_from', sortDir: 'desc', filters: [{ field_key: 'customer_no', value: String(cust), data_type: 'string' }] }), [cust], !!cust); + useEffect(() => { if (hash === '#dues') document.getElementById('dues')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [hash, d.data]); if (!cust) return This login is not mapped to a dealer.; const rows = q.data ?? []; const final = rows.filter((r) => r.status === 'FINAL'); const prov = rows.filter((r) => r.status === 'PROVISIONAL'); const paid = final.reduce((a, r) => a + num(r.incentive_amount), 0); const accrued = prov.reduce((a, r) => a + num(r.incentive_amount), 0); - const cols = [ - { key: 'period_from', label: 'Period', render: (r: Record) => `${date(r.period_from)} – ${date(r.period_to)}` }, - { key: 'scheme_name', label: 'Scheme', render: (r: Record) => {String(r.scheme_name)}{String(r.component_name)} }, - { key: 'measured_qty', label: 'Volume', align: 'right' as const, render: (r: Record) => mt(r.measured_qty) }, - { key: 'slab_label', label: 'Slab', render: (r: Record) => (r.gate_passed ? String(r.slab_label ?? '—') : {String(r.gate_reason ?? 'gate')}) }, - { key: 'rate', label: 'Rate', align: 'right' as const, render: (r: Record) => (r.rate != null ? `₹${num(r.rate)} ${String(r.rate_unit) === 'RS_PER_BAG' ? '/bag' : String(r.rate_unit) === 'RS_PER_MT' ? '/MT' : ''}` : '—') }, - { key: 'incentive_amount', label: 'Amount', align: 'right' as const, render: (r: Record) => (r.in_kind_item ? String(r.in_kind_item) : {inr(r.incentive_amount)}) }, - { key: 'status', label: 'Status', render: (r: Record) => {String(r.status)} }, - { key: 'credit_note_no', label: 'Credit note', render: (r: Record) => (r.credit_note_no ? {String(r.credit_note_no)}{String(r.settlement_status)} · {date(r.posted_on ?? r.scheduled_on)} : r.settlement_status ? String(r.settlement_status) : '—') }, - ]; + const s = d.data?.snapshot; return (
-

Incentives

-
- num(r.incentive_amount) > 0).length} payouts`} tone="emerald" /> - - num(r.incentive_amount) > 0).length)} sub={`of ${prov.length} live`} /> - !r.gate_passed).length)} sub="something to clear" tone={prov.some((r) => !r.gate_passed) ? 'amber' : undefined} /> +

Earnings

+
+ num(r.incentive_amount) > 0).length} credit notes`} tone="emerald" /> + num(r.incentive_amount) > 0).length} of ${prov.length} schemes paying`} tone="accent" />
- - + + {s && ( +
0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel'}`}> +
Dues with Meru
+
{inr(s.open_outstanding)}
+

{s.overdue > 0 ? `${inr(s.overdue)} is past ${s.payment_terms_days} days. ` : 'Nothing overdue. '}Collection schemes count payments Meru receives by month end; pay through your usual bank channel.

+
+ )} + +
+

This period

+ {q.loading && !q.data && } + {q.error && {q.error}} +
    + {prov.length === 0 && !q.loading &&
  • Nothing accruing.
  • } + {prov.map((r) => )} +
+
+ +
+ Settled · {final.length} rows +
    + {final.map((r) => )} +
+
); } + +function TrackRow({ r, settled = false }: { r: Row; settled?: boolean }) { + const amt = num(r.incentive_amount); + return ( +
  • +
    +
    +
    {String(r.scheme_name)}
    +
    {String(r.component_name)} · {mt(r.measured_qty)} · {date(r.period_from)} – {date(r.period_to)}
    + {!r.gate_passed && r.gate_reason ?
    {String(r.gate_reason)}
    : null} + {settled && r.credit_note_no ?
    CN {String(r.credit_note_no)} · {String(r.settlement_status)} · {date(r.posted_on ?? r.scheduled_on)}
    : null} +
    +
    +
    0 ? 'text-ink' : 'text-faint'}`}>{r.in_kind_item ? String(r.in_kind_item) : inr(amt)}
    + 0 ? 'amber' : 'slate'}>{r.status === 'PROVISIONAL' ? (amt > 0 ? 'accruing' : 'not yet') : String(r.status).toLowerCase()} +
    +
    +
  • + ); +} diff --git a/src/screens/dealer/NewOrder.tsx b/src/screens/dealer/NewOrder.tsx index 62d1db3..b18ffc8 100644 --- a/src/screens/dealer/NewOrder.tsx +++ b/src/screens/dealer/NewOrder.tsx @@ -1,20 +1,17 @@ import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Sparkles } from 'lucide-react'; +import { ChevronDown, Minus, Package, Plus, Sparkles } from 'lucide-react'; import { useZino } from '../../api/provider'; -import { ACT, WF } from '../../api/config'; -import { Button, Card, ErrorNote, Input, Label, Select, Spinner, Textarea } from '../../components/core'; -import { SimSummary, SimTable, useSimulate } from '../../components/widgets'; -import { date, mt, num, parseJson } from '../../format'; -import { useDealer, useDealerRow } from './useDealer'; - -type Opt = { code: string; name: string; premium?: boolean; cluster?: string; is_default?: boolean; type?: string }; +import { Button, ErrorNote, Input, Label, Select, Spinner } from '../../components/core'; +import { SimTable, useSimulate } from '../../components/widgets'; +import { inr, mt, num, parseJson } from '../../format'; +import { placeDraft, placeOneTap, plusDays, useDealer, useDealerRow } from './useDealer'; +import type { Opt } from './useDealer'; /** - * Place an order with the engine beside you. Every change to quantity, - * material or ship-to re-runs simulate() for this dealer with the order as the - * what-if; the panel shows what the order earns and how far the next slab is. - * Saving creates a Draft; the Scheme Advisor then reads it and writes advice. + * Place an order on a phone. Pick a product, set the tonnes, and the engine + * says what the order earns before you tap. "Place order" confirms it now; + * "Ask the Advisor" saves a draft the AI reads and writes advice on. */ export function NewOrder() { const { client, portal } = useZino(); @@ -24,79 +21,92 @@ export function NewOrder() { const row = useDealerRow(cust); const materials = useMemo(() => parseJson(row.data?.materials_json, []), [row.data]); const shipTos = useMemo(() => parseJson(row.data?.ship_tos_json, []), [row.data]); - const plants = useMemo(() => parseJson(row.data?.plants_json, []), [row.data]); const asOf = d.data?.as_of ?? ''; - const defaultDate = asOf ? new Date(new Date(asOf).getTime() + 3 * 86400000).toISOString().slice(0, 10) : ''; const [material, setMaterial] = useState(''); - const [qty, setQty] = useState('20'); + const [qty, setQty] = useState(20); const [shipTo, setShipTo] = useState(''); - const [plant, setPlant] = useState(''); const [when, setWhen] = useState(''); - const [notes, setNotes] = useState(''); - const [busy, setBusy] = useState(false); + const [more, setMore] = useState(false); + const [busy, setBusy] = useState<'now' | 'ai' | null>(null); const [err, setErr] = useState(null); const mat = material || String(row.data?.default_material ?? materials[0]?.code ?? ''); const st = shipTo || String(row.data?.default_ship_to ?? shipTos[0]?.code ?? ''); - const pl = plant || String(row.data?.default_plant ?? plants[0]?.code ?? ''); - const dt = when || defaultDate; + const pl = String(row.data?.default_plant ?? ''); + const dt = when || plusDays(asOf, 3); const isPremium = materials.find((m) => m.code === mat)?.premium ?? false; - const q = num(qty); - const hypo = useMemo(() => (q > 0 ? { ...(isPremium ? { extra_pp_mt: q } : { extra_mt: q }), ship_to: st || undefined, plant: pl || undefined, order_on: dt || undefined } : null), [q, isPremium, st, pl, dt]); - const sim = useSimulate(cust, hypo); + const hypo = useMemo(() => (qty > 0 ? { ...(isPremium ? { extra_pp_mt: qty } : { extra_mt: qty }), ship_to: st || undefined, plant: pl || undefined, order_on: dt || undefined } : null), [qty, isPremium, st, pl, dt]); + const sim = useSimulate(cust, hypo, 350); + const rows = sim.data ?? []; + const now = rows.reduce((a, r) => a + num(r.payout_now), 0); + const after = rows.reduce((a, r) => a + num(r.payout_if), 0); + const inc = after - now; + const movers = [...rows].filter((r) => num(r.incremental) !== 0).sort((a, b) => num(b.incremental) - num(a.incremental)).slice(0, 3); const nextSlab = useMemo(() => { - const cands = (sim.data ?? []).filter((r) => r.basis === 'MONTHLY_VOLUME' && r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0); - return cands.length ? Math.min(...cands.map((r) => num(r.mt_to_next_slab))) : null; - }, [sim.data]); + const c = rows.filter((r) => r.basis === 'MONTHLY_VOLUME' && r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0); + return c.length ? Math.ceil(Math.min(...c.map((r) => num(r.mt_to_next_slab)))) : null; + }, [rows]); - async function save() { - if (!cust || q <= 0 || !mat) { setErr('Pick a material and a quantity'); return; } - setBusy(true); setErr(null); + async function go(mode: 'now' | 'ai') { + if (!cust || qty <= 0 || !mat) { setErr('Pick a product and a quantity'); return; } + setBusy(mode); setErr(null); + const o = { customer_no: cust, dealer_name: d.data?.snapshot?.name ?? '', ship_to_code: st, plant_code: pl, material_code: mat, qty_mt: qty, requested_date: dt, as_of: asOf, advice_accepted: false }; try { - const res = await client.startInstance(WF.ORDER, ACT.PLACE, { - customer_no__place: cust, dealer_name__place: d.data?.snapshot?.name ?? '', ship_to_code__place: st, plant_code__place: pl, - material_code__place: mat, qty_mt__place: q, requested_date__place: dt, as_of__place: asOf, notes__place: notes, - }); - navigate(`/d/orders/${res.instance_id}`); - } catch (e) { setErr((e as { message?: string })?.message ?? 'Could not save the order'); } finally { setBusy(false); } + const res = mode === 'now' ? await placeOneTap(client, o) : await placeDraft(client, o); + navigate(`/d/orders/${res.instance_id}${mode === 'now' ? '?placed=1' : '?asked=1'}`); + } catch (e) { setErr((e as { message?: string })?.message ?? 'Could not save the order'); } finally { setBusy(null); } } if (!cust) return This login is not mapped to a dealer.; if ((row.loading && !row.data) || (d.loading && !d.data)) return ; return ( -
    - -
    - {err && {err}} -
    -
    - {[10, 20, 30, 50].map((n) => )} - {nextSlab != null && } -
    -
    -
    ({ value: p.code, label: `${p.name} · ${p.type}` }))} />
    -
    -