Compare commits
No commits in common. "d709555d2865079b363cdaeed4c1c2ab9be4761c" and "134db9a06c29f23ac45017f8d7118ef1c36925d3" have entirely different histories.
d709555d28
...
134db9a06c
26
src/App.tsx
26
src/App.tsx
@ -1,28 +1,26 @@
|
|||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { ClipboardList, Home, IndianRupee, PlusCircle, Tag } from 'lucide-react';
|
|
||||||
import { useZino } from './api/provider';
|
import { useZino } from './api/provider';
|
||||||
import { Shell } from './layout/Shell';
|
import { Shell } from './layout/Shell';
|
||||||
import type { NavItem } from './layout/Shell';
|
import type { NavItem } from './layout/Shell';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Spinner } from './components/core';
|
import { Spinner } from './components/core';
|
||||||
import { Today } from './screens/dealer/DealerHome';
|
import { DealerHome } from './screens/dealer/DealerHome';
|
||||||
import { NewOrder } from './screens/dealer/NewOrder';
|
import { NewOrder } from './screens/dealer/NewOrder';
|
||||||
import { DealerOrders, OrderDetail } from './screens/dealer/Orders';
|
import { DealerOrders, OrderDetail } from './screens/dealer/Orders';
|
||||||
import { DealerSchemes } from './screens/dealer/Schemes';
|
import { DealerSchemes } from './screens/dealer/Schemes';
|
||||||
import { Earnings } from './screens/dealer/Incentives';
|
import { DealerIncentives } from './screens/dealer/Incentives';
|
||||||
import { Overview } from './screens/supplier/Overview';
|
import { Overview } from './screens/supplier/Overview';
|
||||||
import { Dealers, Dealer360 } from './screens/supplier/Dealers';
|
import { Dealers, Dealer360 } from './screens/supplier/Dealers';
|
||||||
import { Catalogue } from './screens/supplier/Catalogue';
|
import { Catalogue } from './screens/supplier/Catalogue';
|
||||||
import { Runs } from './screens/supplier/Runs';
|
import { Runs } from './screens/supplier/Runs';
|
||||||
import { Advisories, AdvisoryDetail } from './screens/supplier/Advisories';
|
import { Advisories, AdvisoryDetail } from './screens/supplier/Advisories';
|
||||||
|
|
||||||
/** The dealer app is a phone: five tabs, one thumb. */
|
|
||||||
const DEALER_NAV: NavItem[] = [
|
const DEALER_NAV: NavItem[] = [
|
||||||
{ to: '/d', label: 'Today', hint: 'What to do today', end: true, icon: Home },
|
{ to: '/d', label: 'Home', hint: 'This month, at a glance', end: true },
|
||||||
{ to: '/d/order', label: 'Order', hint: 'Place an order', icon: PlusCircle },
|
{ to: '/d/order/new', label: 'Place order', hint: 'With the Advisor beside you' },
|
||||||
{ to: '/d/orders', label: 'Orders', hint: 'My orders', icon: ClipboardList },
|
{ to: '/d/orders', label: 'My orders', hint: 'Drafts, advice, confirmations' },
|
||||||
{ to: '/d/schemes', label: 'Schemes', hint: 'What pays', icon: Tag },
|
{ to: '/d/schemes', label: 'My schemes', hint: 'What pays, and what is close' },
|
||||||
{ to: '/d/earnings', label: 'Earnings', hint: 'Paid and accruing', icon: IndianRupee },
|
{ to: '/d/incentives', label: 'Incentives', hint: 'Tracking and credit notes' },
|
||||||
];
|
];
|
||||||
const SUPPLIER_NAV: NavItem[] = [
|
const SUPPLIER_NAV: NavItem[] = [
|
||||||
{ to: '/s', label: 'Territory', hint: 'Clusters and the near-slab sweep', end: true },
|
{ to: '/s', label: 'Territory', hint: 'Clusters and the near-slab sweep', end: true },
|
||||||
@ -44,15 +42,13 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{isDealer && (
|
{isDealer && (
|
||||||
<Route element={<Shell items={DEALER_NAV} sub="Dealer app" mobile />}>
|
<Route element={<Shell items={DEALER_NAV} sub="Dealer portal" />}>
|
||||||
<Route path="/d" element={<Today />} />
|
<Route path="/d" element={<DealerHome />} />
|
||||||
<Route path="/d/order" element={<NewOrder />} />
|
<Route path="/d/order/new" element={<NewOrder />} />
|
||||||
<Route path="/d/order/new" element={<Navigate to="/d/order" replace />} />
|
|
||||||
<Route path="/d/orders" element={<DealerOrders />} />
|
<Route path="/d/orders" element={<DealerOrders />} />
|
||||||
<Route path="/d/orders/:id" element={<OrderDetail />} />
|
<Route path="/d/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/d/schemes" element={<DealerSchemes />} />
|
<Route path="/d/schemes" element={<DealerSchemes />} />
|
||||||
<Route path="/d/earnings" element={<Earnings />} />
|
<Route path="/d/incentives" element={<DealerIncentives />} />
|
||||||
<Route path="/d/incentives" element={<Navigate to="/d/earnings" replace />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
)}
|
)}
|
||||||
{(isSupplier || !isDealer) && (
|
{(isSupplier || !isDealer) && (
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const WF = {
|
|||||||
|
|
||||||
export const ACT = {
|
export const ACT = {
|
||||||
PLACE: 'dsa-act-place',
|
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',
|
CONFIRM: 'dsa-act-confirm',
|
||||||
CANCEL: 'dsa-act-cancel',
|
CANCEL: 'dsa-act-cancel',
|
||||||
DISPATCH: 'dsa-act-dispatch',
|
DISPATCH: 'dsa-act-dispatch',
|
||||||
@ -51,8 +49,6 @@ export const RV = {
|
|||||||
SNAPSHOT: 'dsa-rv-dealer_snapshot',
|
SNAPSHOT: 'dsa-rv-dealer_snapshot',
|
||||||
HISTORY: 'dsa-rv-monthly_history',
|
HISTORY: 'dsa-rv-monthly_history',
|
||||||
UPCOMING: 'dsa-rv-upcoming_schemes',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet' | 'accent';
|
export type Tone = 'slate' | 'amber' | 'sky' | 'emerald' | 'rose' | 'violet' | 'accent';
|
||||||
|
|||||||
@ -45,12 +45,3 @@ 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 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; }
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -32,8 +32,8 @@ const BUTTON_KIND: Record<ButtonKind, string> = {
|
|||||||
accent: 'bg-accent-soft text-accent ring-1 ring-inset ring-accent-edge hover:bg-panel3',
|
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' }:
|
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' | 'lg' }) {
|
{ 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' : size === 'lg' ? 'px-5 py-3.5 text-base' : 'px-3.5 py-2 text-sm';
|
const pad = size === 'sm' ? 'px-2.5 py-1.5 text-xs' : 'px-3.5 py-2 text-sm';
|
||||||
return (
|
return (
|
||||||
<button type={type} title={title} onClick={onClick} disabled={disabled || busy}
|
<button type={type} title={title} onClick={onClick} disabled={disabled || busy}
|
||||||
className={`inline-flex items-center justify-center gap-2 rounded-md font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-not-allowed disabled:opacity-50 ${pad} ${BUTTON_KIND[kind]} ${className}`}>
|
className={`inline-flex items-center justify-center gap-2 rounded-md font-semibold transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-not-allowed disabled:opacity-50 ${pad} ${BUTTON_KIND[kind]} ${className}`}>
|
||||||
@ -99,8 +99,8 @@ export function Modal({ open, onClose, title, subtitle, children, wide = false }
|
|||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-scrim sm:items-start sm:p-8">
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-scrim p-4 sm:p-8">
|
||||||
<div role="dialog" aria-modal="true" className={`lift-lg sheet w-full rounded-t-2xl border border-line bg-panel sm:rounded-lg ${wide ? 'max-w-3xl' : 'max-w-xl'}`}>
|
<div role="dialog" aria-modal="true" className={`lift-lg w-full rounded-lg border border-line bg-panel ${wide ? 'max-w-3xl' : 'max-w-xl'}`}>
|
||||||
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||||
<div className="min-w-0"><h2 className="text-base font-semibold text-ink">{title}</h2>{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}</div>
|
<div className="min-w-0"><h2 className="text-base font-semibold text-ink">{title}</h2>{subtitle && <p className="mt-0.5 text-xs text-faint">{subtitle}</p>}</div>
|
||||||
<button onClick={onClose} aria-label="Close" className="-m-1 rounded p-1 text-faint hover:bg-panel2 hover:text-ink"><X className="h-4 w-4" /></button>
|
<button onClick={onClose} aria-label="Close" className="-m-1 rounded p-1 text-faint hover:bg-panel2 hover:text-ink"><X className="h-4 w-4" /></button>
|
||||||
@ -141,19 +141,3 @@ export function Bar({ value, max, className = '' }: { value: number; max: number
|
|||||||
const p = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
|
const p = max > 0 ? Math.max(0, Math.min(100, (value / max) * 100)) : 0;
|
||||||
return <div className={`bar ${className}`}><i style={{ width: `${p}%` }} /></div>;
|
return <div className={`bar ${className}`}><i style={{ width: `${p}%` }} /></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 <Modal open={open} onClose={onClose} title={title}>{children}</Modal>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<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">{label}</div>
|
|
||||||
<div className={`tnum mt-0.5 truncate text-[17px] leading-tight font-semibold ${tone ? TONE_TEXT[tone] : 'text-ink'}`}>{value}</div>
|
|
||||||
{sub && <div className="mt-0.5 truncate text-[11px] text-muted">{sub}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,12 +6,7 @@ import { KIND_LABEL, RV, STATE_TONE } from '../api/config';
|
|||||||
import type { Tone } from '../api/config';
|
import type { Tone } from '../api/config';
|
||||||
import type { AiDecision, AuditEntry, Row, SimRow } from '../api/types';
|
import type { AiDecision, AuditEntry, Row, SimRow } from '../api/types';
|
||||||
import { Badge, Bar, Card, Empty, ErrorNote, Spinner } from './core';
|
import { Badge, Bar, Card, Empty, ErrorNote, Spinner } from './core';
|
||||||
import { date, dateTime, inr, mdParts, mt, num, pct } from '../format';
|
import { date, dateTime, inr, mt, num, pct } from '../format';
|
||||||
|
|
||||||
/** AI prose with its **bold** honoured. */
|
|
||||||
export function Prose({ text, className = '' }: { text: unknown; className?: string }) {
|
|
||||||
return <p className={`whitespace-pre-line ${className}`}>{mdParts(text).map((x, i) => (x.bold ? <b key={i} className="font-semibold text-ink">{x.text}</b> : <span key={i}>{x.text}</span>))}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateBadge({ state }: { state: unknown }) {
|
export function StateBadge({ state }: { state: unknown }) {
|
||||||
const s = String(state ?? '');
|
const s = String(state ?? '');
|
||||||
@ -80,7 +75,7 @@ export function RecCard({ rec, compact = false }: { rec: Row; compact?: boolean
|
|||||||
<p className="text-sm font-semibold text-ink">{String(rec.headline ?? '')}</p>
|
<p className="text-sm font-semibold text-ink">{String(rec.headline ?? '')}</p>
|
||||||
{inc !== 0 && <div className={`tnum shrink-0 text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</div>}
|
{inc !== 0 && <div className={`tnum shrink-0 text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</div>}
|
||||||
</div>
|
</div>
|
||||||
{!compact && rec.rationale_md ? <Prose text={rec.rationale_md} className="mt-2 text-sm text-muted" /> : null}
|
{!compact && rec.rationale_md ? <p className="mt-2 text-sm whitespace-pre-line text-muted">{String(rec.rationale_md)}</p> : null}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-faint">
|
<div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs text-faint">
|
||||||
{rec.delta_mt ? <span>Add <b className="text-ink">{mt(rec.delta_mt)}</b></span> : null}
|
{rec.delta_mt ? <span>Add <b className="text-ink">{mt(rec.delta_mt)}</b></span> : null}
|
||||||
@ -177,14 +172,12 @@ export function SimTable({ rows, hasHypo, loading, error }: { rows: SimRow[] | n
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// What happened on an instance: the audit trail + the AI's own reasoning.
|
// What happened on an instance: the audit trail + the AI's own reasoning.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
export function InstanceTrace({ instanceId, bare = false }: { instanceId: number | string; bare?: boolean }) {
|
export function InstanceTrace({ instanceId }: { instanceId: number | string }) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const audit = useQuery(() => client.audit(instanceId), [instanceId], true, 15000);
|
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;
|
const ai = useQuery(() => client.aiDecisions(instanceId).catch(() => [] as AiDecision[]), [instanceId], true, 15000);
|
||||||
// /monitor/* is Studio-scoped on this build (403 for app users), so it is not called.
|
return (
|
||||||
const ai = { data: [] as AiDecision[] };
|
<Card title="Trail" subtitle="Every step on this record, and what the AI reasoned.">
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
{audit.loading && !audit.data && <Spinner />}
|
{audit.loading && !audit.data && <Spinner />}
|
||||||
{audit.error && <div className="p-4"><ErrorNote>{audit.error}</ErrorNote></div>}
|
{audit.error && <div className="p-4"><ErrorNote>{audit.error}</ErrorNote></div>}
|
||||||
<ol className="divide-y divide-line">
|
<ol className="divide-y divide-line">
|
||||||
@ -200,8 +193,8 @@ export function InstanceTrace({ instanceId, bare = false }: { instanceId: number
|
|||||||
</div>
|
</div>
|
||||||
{a.fields && a.fields.length > 0 && (
|
{a.fields && a.fields.length > 0 && (
|
||||||
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
||||||
{a.fields.filter((f) => f.value !== '' && f.value != null && !f.field_id.startsWith('_') && !f.label.startsWith('_')).slice(0, 14).map((f) => (
|
{a.fields.filter((f) => f.value !== '' && f.value != null).slice(0, 12).map((f) => (
|
||||||
<div key={f.field_id} className={`min-w-0 ${String(f.value).length > 160 ? 'sm:col-span-2' : ''}`}><dt className="inline text-faint">{f.label}: </dt><dd className="inline break-words whitespace-pre-line text-ink">{String(f.value).length > 700 ? String(f.value).slice(0, 700) + '…' : String(f.value)}</dd></div>
|
<div key={f.field_id} className="min-w-0"><dt className="inline text-faint">{f.label}: </dt><dd className="inline break-words text-ink">{String(f.value).slice(0, 300)}</dd></div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
@ -216,8 +209,6 @@ export function InstanceTrace({ instanceId, bare = false }: { instanceId: number
|
|||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
{audit.data && audit.data.length === 0 && <Empty>No steps recorded yet.</Empty>}
|
{audit.data && audit.data.length === 0 && <Empty>No steps recorded yet.</Empty>}
|
||||||
</>
|
</Card>
|
||||||
);
|
);
|
||||||
if (bare) return body;
|
|
||||||
return <Card title="Trail" subtitle="Every step on this record, and what the AI reasoned.">{body}</Card>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,18 +43,3 @@ export function parseJson<T>(v: unknown, fallback: T): T {
|
|||||||
if (typeof v === 'object') return v as T;
|
if (typeof v === 'object') return v as T;
|
||||||
try { return JSON.parse(String(v)) as T; } catch { return fallback; }
|
try { return JSON.parse(String(v)) as T; } catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The AI writes **bold** in its prose; render just that, as React nodes. */
|
|
||||||
export function mdParts(text: unknown): Array<{ bold: boolean; text: string }> {
|
|
||||||
const t = String(text ?? '');
|
|
||||||
const out: Array<{ bold: boolean; text: string }> = [];
|
|
||||||
const re = /\*\*([^*]+)\*\*/g;
|
|
||||||
let last = 0; let m: RegExpExecArray | null;
|
|
||||||
while ((m = re.exec(t))) {
|
|
||||||
if (m.index > last) out.push({ bold: false, text: t.slice(last, m.index) });
|
|
||||||
out.push({ bold: true, text: m[1] });
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < t.length) out.push({ bold: false, text: t.slice(last) });
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,53 +1,16 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
import { LogOut } from 'lucide-react';
|
import { LogOut } from 'lucide-react';
|
||||||
import { useZino } from '../api/provider';
|
import { useZino } from '../api/provider';
|
||||||
import { prettyRole } from '../api/config';
|
import { prettyRole } from '../api/config';
|
||||||
import { AppLogo, MeruMark } from '../components/Logo';
|
import { AppLogo } from '../components/Logo';
|
||||||
import { ThemeToggle } from '../components/ThemeToggle';
|
import { ThemeToggle } from '../components/ThemeToggle';
|
||||||
|
|
||||||
export interface NavItem { to: string; label: string; hint: string; end?: boolean; icon?: LucideIcon; }
|
export interface NavItem { to: string; label: string; hint: string; end?: boolean; }
|
||||||
|
|
||||||
/** The chrome. Nothing here is role-gated beyond choosing which nav to show:
|
/** The chrome. Nothing here is role-gated beyond choosing which nav to show:
|
||||||
* the workflow re-checks RBAC on every submission.
|
* 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 }: { items: NavItem[]; sub: string }) {
|
||||||
export function Shell({ items, sub, mobile = false }: { items: NavItem[]; sub: string; mobile?: boolean }) {
|
|
||||||
const { user, portal, logout } = useZino();
|
const { user, portal, logout } = useZino();
|
||||||
if (mobile) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex min-h-full w-full max-w-md flex-col bg-bg">
|
|
||||||
<header className="sticky top-0 z-30 bg-panel">
|
|
||||||
<div className="flex items-center gap-3 px-4 py-2.5">
|
|
||||||
<MeruMark size={30} />
|
|
||||||
<div className="min-w-0 leading-tight">
|
|
||||||
<div className="truncate text-[15px] font-bold tracking-tight text-ink">{portal?.display_name || user?.name || 'Meru Cement'}</div>
|
|
||||||
<div className="truncate text-[11px] font-medium text-faint">{portal?.customer_no ? `${portal.customer_no} · ` : ''}{sub}</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
<ThemeToggle />
|
|
||||||
<button onClick={logout} title="Sign out" aria-label="Sign out" className="rounded-md p-2 text-muted hover:bg-panel2 hover:text-ink"><LogOut className="h-4 w-4" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div aria-hidden="true" className="h-[3px] bg-brand" />
|
|
||||||
</header>
|
|
||||||
<main className="min-w-0 flex-1 px-4 pt-4 pb-24"><Outlet /></main>
|
|
||||||
<nav className="tabbar fixed inset-x-0 bottom-0 z-30 mx-auto w-full max-w-md border-t border-line bg-panel">
|
|
||||||
<ul className="grid grid-cols-5">
|
|
||||||
{items.map((q) => {
|
|
||||||
const Icon = q.icon;
|
|
||||||
return (
|
|
||||||
<li key={q.to}>
|
|
||||||
<NavLink to={q.to} end={q.end} className={({ isActive }) => `flex flex-col items-center gap-0.5 px-1 pt-2 pb-1.5 text-[11px] font-semibold ${isActive ? 'text-brand' : 'text-faint'}`}>
|
|
||||||
{({ isActive }) => (<><span className={`rounded-full px-3 py-0.5 ${isActive ? 'bg-brand/12' : ''}`}>{Icon && <Icon className="h-5 w-5" strokeWidth={isActive ? 2.4 : 2} />}</span><span>{q.label}</span></>)}
|
|
||||||
</NavLink>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
<header className="sticky top-0 z-30 bg-panel">
|
<header className="sticky top-0 z-30 bg-panel">
|
||||||
|
|||||||
@ -1,185 +1,102 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Link } from 'react-router-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { ArrowRight, Sparkles } from 'lucide-react';
|
||||||
import { Bell, CalendarDays, CheckCircle2, ChevronDown, IndianRupee, Package, Sparkles } from 'lucide-react';
|
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { RV } from '../../api/config';
|
import { RV } from '../../api/config';
|
||||||
import type { RecRow } from '../../api/types';
|
import { Badge, Bar, Button, Card, Empty, ErrorNote, Kpi, Spinner } from '../../components/core';
|
||||||
import { Bar, Button, Chip, ErrorNote, Input, Label, Select, Sheet, Spinner } from '../../components/core';
|
import { RecCard, SimTable, useSimulate } from '../../components/widgets';
|
||||||
import { useSimulate } from '../../components/widgets';
|
import { date, inr, mt, num, pct } from '../../format';
|
||||||
import { date, inr, mt, num, parseJson } from '../../format';
|
import { useDealer } from './useDealer';
|
||||||
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' };
|
export function DealerHome() {
|
||||||
|
|
||||||
/** Today: three numbers, then the cards. Each card is one action and one button. */
|
|
||||||
export function Today() {
|
|
||||||
const { client, portal } = useZino();
|
const { client, portal } = useZino();
|
||||||
const navigate = useNavigate();
|
|
||||||
const cust = portal?.customer_no;
|
const cust = portal?.customer_no;
|
||||||
const d = useDealer(cust);
|
const d = useDealer(cust);
|
||||||
const row = useDealerRow(cust);
|
|
||||||
const recs = useRecommend(cust);
|
|
||||||
const sim = useSimulate(cust, null);
|
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 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 (!portal) return <Spinner label="Finding your dealer account…" />;
|
||||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||||
if (d.loading && !d.data) return <Spinner />;
|
if (d.loading && !d.data) return <Spinner />;
|
||||||
if (d.error) return <ErrorNote>{d.error}</ErrorNote>;
|
if (d.error) return <ErrorNote>{d.error}</ErrorNote>;
|
||||||
const s = d.data?.snapshot;
|
const s = d.data?.snapshot;
|
||||||
if (!s) return <ErrorNote>No snapshot for {cust}.</ErrorNote>;
|
if (!s) return <Empty>No snapshot for {cust}.</Empty>;
|
||||||
|
const st = d.data?.standing;
|
||||||
const asOf = d.data?.as_of ?? '';
|
const asOf = d.data?.as_of ?? '';
|
||||||
const monthEnd = asOf ? new Date(new Date(asOf).getFullYear(), new Date(asOf).getMonth() + 1, 0) : null;
|
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 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 earning = (sim.data ?? []).reduce((a, r) => a + num(r.payout_now), 0);
|
||||||
const cards = (recs.data ?? []).filter((r) => r.kind !== 'NOTHING_TO_DO');
|
const top = recs.data?.[0];
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div>
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<h1 className="text-lg font-semibold text-ink">{greeting()}, {firstName(s.name)}</h1>
|
<div>
|
||||||
<p className="text-[13px] text-muted">{daysLeft != null ? `${daysLeft} days left in ${monthName(asOf)}` : date(asOf)} · {s.cluster} cluster</p>
|
<h1 className="text-xl font-semibold text-ink">{s.name}</h1>
|
||||||
</div>
|
<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">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
{s.gold_star && <Badge tone="accent">Gold Star</Badge>}
|
||||||
<div className="lift min-w-0 rounded-lg border border-line bg-panel px-3 py-2.5">
|
{s.silver_star && <Badge tone="sky">Silver Star</Badge>}
|
||||||
<div className="truncate text-[10px] font-semibold tracking-wide text-faint uppercase">This month</div>
|
{s.is_new_dealer && <Badge tone="emerald">New dealer</Badge>}
|
||||||
<div className="tnum mt-0.5 text-[17px] leading-tight font-semibold text-ink">{mt(s.mtd_mt, 0)}</div>
|
{s.is_inactive && <Badge tone="amber">Revival</Badge>}
|
||||||
<div className="mt-0.5 truncate text-[11px] text-muted">of {mt(s.month_target_mt, 0)}</div>
|
{st?.is_weak_market && <Badge tone="slate">Weak-market cluster</Badge>}
|
||||||
<Bar value={s.mtd_mt} max={s.month_target_mt} className="mt-1.5" />
|
{st?.is_border && <Badge tone="slate">Border cluster</Badge>}
|
||||||
|
{s.loyalty_tier && <Badge tone="slate">{s.loyalty_tier} tier</Badge>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Chip label="Earning" value={inr(earning, { compact: earning >= 100000 })} sub="this period" tone="accent" />
|
<Link to="/d/order/new"><Button kind="primary">Place an order <ArrowRight className="h-4 w-4" /></Button></Link>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<section>
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
|
||||||
<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>
|
<div className="lift rounded-lg border border-line bg-panel px-4 py-3">
|
||||||
{recs.loading && !recs.data && <Spinner label="Checking your schemes…" />}
|
<div className="text-[11px] font-semibold tracking-wide text-faint uppercase">Month to date</div>
|
||||||
{recs.error && <ErrorNote>{recs.error}</ErrorNote>}
|
<div className="tnum mt-1 text-xl font-semibold text-ink">{mt(s.mtd_mt)}</div>
|
||||||
<div className="space-y-3">
|
<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>
|
||||||
{cards.map((r) => (
|
<Bar value={s.mtd_mt} max={s.month_target_mt} className="mt-2" />
|
||||||
<ActionCard key={r.rank} rec={r} reminded={!!reminded[r.kind + (r.book_on ?? '')]}
|
</div>
|
||||||
onAct={() => { if (r.action === 'ORDER' || r.action === 'ORDER_PP') setAct(r); else if (r.action === 'PAY') navigate('/d/earnings#dues'); else remind(r); }} />
|
<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" />
|
||||||
{recs.data && cards.length === 0 && (
|
<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} />
|
||||||
<div className="rounded-lg border border-good-edge bg-good-soft p-4">
|
<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 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>
|
</div>
|
||||||
<p className="mt-1 text-[13px] text-muted">{nothing?.detail ?? 'No extra order or payment earns more right now.'}</p>
|
|
||||||
|
<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>
|
</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>
|
||||||
</section>
|
<div className="space-y-5">
|
||||||
|
<Card title="Coming up" subtitle="Schemes that open soon — worth timing an order for.">
|
||||||
{(upcoming.data ?? []).length > 0 && (
|
<div className="divide-y divide-line">
|
||||||
<section>
|
{(upcoming.data ?? []).length === 0 && <Empty>Nothing opening in the next 45 days.</Empty>}
|
||||||
<h2 className="mb-2 text-[13px] font-semibold tracking-wide text-muted uppercase">Coming up</h2>
|
{(upcoming.data ?? []).map((u) => (
|
||||||
<ul className="divide-y divide-line rounded-lg border border-line bg-panel">
|
<div key={String(u.component_id)} className="px-4 py-3">
|
||||||
{(upcoming.data ?? []).slice(0, 3).map((u) => (
|
<div className="text-sm font-semibold text-ink">{String(u.scheme_name)}</div>
|
||||||
<li key={String(u.component_id)} className="flex items-center gap-3 px-3 py-2.5">
|
<div className="text-xs text-muted">opens {date(u.validity_from)} · in {String(u.starts_in_days)} days · to {date(u.validity_to)}</div>
|
||||||
<CalendarDays className="h-4 w-4 shrink-0 text-faint" />
|
<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 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>
|
</div>
|
||||||
</li>
|
))}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
</Card>
|
||||||
</section>
|
<Card title="Open orders">
|
||||||
)}
|
<div className="divide-y divide-line">
|
||||||
|
{s.open_orders.length === 0 && <Empty>No open orders in the estate.</Empty>}
|
||||||
{act && (
|
{s.open_orders.map((o) => (
|
||||||
<OneTapSheet rec={act} onClose={() => setAct(null)} cust={cust} dealerName={s.name} asOf={asOf} row={row.data}
|
<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>
|
||||||
onPlaced={(id) => { setAct(null); navigate(`/d/orders/${id}?placed=1`); }} />
|
))}
|
||||||
)}
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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' }) : ''; }
|
|
||||||
|
|||||||
@ -1,79 +1,40 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { RV } from '../../api/config';
|
import { RV } from '../../api/config';
|
||||||
import type { Row } from '../../api/types';
|
import { Badge, Card, ErrorNote, Kpi } from '../../components/core';
|
||||||
import { Badge, Chip, ErrorNote, Spinner } from '../../components/core';
|
import { DataTable } from '../../components/widgets';
|
||||||
import { date, inr, mt, num } from '../../format';
|
import { date, inr, mt, num } from '../../format';
|
||||||
import { useDealer } from './useDealer';
|
|
||||||
|
|
||||||
/** Earnings: what was paid, what is accruing, and the dues that gate a collection scheme. */
|
export function DealerIncentives() {
|
||||||
export function Earnings() {
|
|
||||||
const { client, portal } = useZino();
|
const { client, portal } = useZino();
|
||||||
const { hash } = useLocation();
|
|
||||||
const cust = portal?.customer_no;
|
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);
|
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 <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||||
const rows = q.data ?? [];
|
const rows = q.data ?? [];
|
||||||
const final = rows.filter((r) => r.status === 'FINAL');
|
const final = rows.filter((r) => r.status === 'FINAL');
|
||||||
const prov = rows.filter((r) => r.status === 'PROVISIONAL');
|
const prov = rows.filter((r) => r.status === 'PROVISIONAL');
|
||||||
const paid = final.reduce((a, r) => a + num(r.incentive_amount), 0);
|
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 accrued = prov.reduce((a, r) => a + num(r.incentive_amount), 0);
|
||||||
const s = d.data?.snapshot;
|
const cols = [
|
||||||
|
{ key: 'period_from', label: 'Period', render: (r: Record<string, unknown>) => `${date(r.period_from)} – ${date(r.period_to)}` },
|
||||||
|
{ key: 'scheme_name', label: 'Scheme', render: (r: Record<string, unknown>) => <span><span className="text-ink">{String(r.scheme_name)}</span><span className="block text-xs text-faint">{String(r.component_name)}</span></span> },
|
||||||
|
{ key: 'measured_qty', label: 'Volume', align: 'right' as const, render: (r: Record<string, unknown>) => mt(r.measured_qty) },
|
||||||
|
{ key: 'slab_label', label: 'Slab', render: (r: Record<string, unknown>) => (r.gate_passed ? String(r.slab_label ?? '—') : <span className="text-warn">{String(r.gate_reason ?? 'gate')}</span>) },
|
||||||
|
{ key: 'rate', label: 'Rate', align: 'right' as const, render: (r: Record<string, unknown>) => (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<string, unknown>) => (r.in_kind_item ? String(r.in_kind_item) : <span className="font-semibold text-ink">{inr(r.incentive_amount)}</span>) },
|
||||||
|
{ key: 'status', label: 'Status', render: (r: Record<string, unknown>) => <Badge tone={r.status === 'FINAL' ? 'emerald' : r.status === 'HELD' ? 'rose' : 'amber'}>{String(r.status)}</Badge> },
|
||||||
|
{ key: 'credit_note_no', label: 'Credit note', render: (r: Record<string, unknown>) => (r.credit_note_no ? <span>{String(r.credit_note_no)}<span className="block text-xs text-faint">{String(r.settlement_status)} · {date(r.posted_on ?? r.scheduled_on)}</span></span> : r.settlement_status ? String(r.settlement_status) : '—') },
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-semibold text-ink">Earnings</h1>
|
<h1 className="text-xl font-semibold text-ink">Incentives</h1>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
<Chip label="Paid, last 12 months" value={inr(paid, { compact: true })} sub={`${final.filter((r) => num(r.incentive_amount) > 0).length} credit notes`} tone="emerald" />
|
<Kpi label="Released (last 12 months)" value={inr(paid, { compact: true })} sub={`${final.filter((r) => num(r.incentive_amount) > 0).length} payouts`} tone="emerald" />
|
||||||
<Chip label="Accruing now" value={inr(accrued, { compact: true })} sub={`${prov.filter((r) => num(r.incentive_amount) > 0).length} of ${prov.length} schemes paying`} tone="accent" />
|
<Kpi label="Accruing now" value={inr(accrued, { compact: true })} sub="provisional, this period" tone="accent" />
|
||||||
|
<Kpi label="Components paying" value={String(prov.filter((r) => num(r.incentive_amount) > 0).length)} sub={`of ${prov.length} live`} />
|
||||||
|
<Kpi label="Gated" value={String(prov.filter((r) => !r.gate_passed).length)} sub="something to clear" tone={prov.some((r) => !r.gate_passed) ? 'amber' : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
<Card title="This period" subtitle="Provisional: computed as of the latest data, final at period end."><DataTable rows={prov} loading={q.loading} error={q.error} cols={cols} empty="Nothing accruing." /></Card>
|
||||||
{s && (
|
<Card title="Settled" subtitle="Final tracking rows and their credit notes."><DataTable rows={final} loading={q.loading} error={q.error} cols={cols} empty="No settled incentives yet." /></Card>
|
||||||
<section id="dues" className={`rounded-xl border p-4 ${s.overdue > 0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel'}`}>
|
|
||||||
<div className="text-[11px] font-semibold tracking-wide text-muted uppercase">Dues with Meru</div>
|
|
||||||
<div className="tnum hero-num mt-1 font-semibold text-ink">{inr(s.open_outstanding)}</div>
|
|
||||||
<p className="mt-1 text-[13px] text-muted">{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.</p>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-2 text-[13px] font-semibold tracking-wide text-muted uppercase">This period</h2>
|
|
||||||
{q.loading && !q.data && <Spinner />}
|
|
||||||
{q.error && <ErrorNote>{q.error}</ErrorNote>}
|
|
||||||
<ul className="divide-y divide-line rounded-lg border border-line bg-panel">
|
|
||||||
{prov.length === 0 && !q.loading && <li className="px-3 py-6 text-center text-[13px] text-faint">Nothing accruing.</li>}
|
|
||||||
{prov.map((r) => <TrackRow key={String(r.tracking_id)} r={r} />)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details className="rounded-lg border border-line bg-panel">
|
|
||||||
<summary className="cursor-pointer px-3 py-2.5 text-[13px] font-semibold text-ink">Settled · {final.length} rows</summary>
|
|
||||||
<ul className="divide-y divide-line border-t border-line">
|
|
||||||
{final.map((r) => <TrackRow key={String(r.tracking_id)} r={r} settled />)}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrackRow({ r, settled = false }: { r: Row; settled?: boolean }) {
|
|
||||||
const amt = num(r.incentive_amount);
|
|
||||||
return (
|
|
||||||
<li className="px-3 py-2.5">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate text-[13px] font-semibold text-ink">{String(r.scheme_name)}</div>
|
|
||||||
<div className="truncate text-[11px] text-faint">{String(r.component_name)} · {mt(r.measured_qty)} · {date(r.period_from)} – {date(r.period_to)}</div>
|
|
||||||
{!r.gate_passed && r.gate_reason ? <div className="mt-0.5 text-[11px] text-warn">{String(r.gate_reason)}</div> : null}
|
|
||||||
{settled && r.credit_note_no ? <div className="mt-0.5 text-[11px] text-faint">CN {String(r.credit_note_no)} · {String(r.settlement_status)} · {date(r.posted_on ?? r.scheduled_on)}</div> : null}
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0 text-right">
|
|
||||||
<div className={`tnum text-[15px] font-semibold ${amt > 0 ? 'text-ink' : 'text-faint'}`}>{r.in_kind_item ? String(r.in_kind_item) : inr(amt)}</div>
|
|
||||||
<Badge tone={r.status === 'FINAL' ? 'emerald' : r.status === 'HELD' ? 'rose' : amt > 0 ? 'amber' : 'slate'}>{r.status === 'PROVISIONAL' ? (amt > 0 ? 'accruing' : 'not yet') : String(r.status).toLowerCase()}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,20 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ChevronDown, Minus, Package, Plus, Sparkles } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import { useZino } from '../../api/provider';
|
import { useZino } from '../../api/provider';
|
||||||
import { Button, ErrorNote, Input, Label, Select, Spinner } from '../../components/core';
|
import { ACT, WF } from '../../api/config';
|
||||||
import { SimTable, useSimulate } from '../../components/widgets';
|
import { Button, Card, ErrorNote, Input, Label, Select, Spinner, Textarea } from '../../components/core';
|
||||||
import { inr, mt, num, parseJson } from '../../format';
|
import { SimSummary, SimTable, useSimulate } from '../../components/widgets';
|
||||||
import { placeDraft, placeOneTap, plusDays, useDealer, useDealerRow } from './useDealer';
|
import { date, mt, num, parseJson } from '../../format';
|
||||||
import type { Opt } from './useDealer';
|
import { useDealer, useDealerRow } from './useDealer';
|
||||||
|
|
||||||
|
type Opt = { code: string; name: string; premium?: boolean; cluster?: string; is_default?: boolean; type?: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Place an order on a phone. Pick a product, set the tonnes, and the engine
|
* Place an order with the engine beside you. Every change to quantity,
|
||||||
* says what the order earns before you tap. "Place order" confirms it now;
|
* material or ship-to re-runs simulate() for this dealer with the order as the
|
||||||
* "Ask the Advisor" saves a draft the AI reads and writes advice on.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export function NewOrder() {
|
export function NewOrder() {
|
||||||
const { client, portal } = useZino();
|
const { client, portal } = useZino();
|
||||||
@ -21,92 +24,79 @@ export function NewOrder() {
|
|||||||
const row = useDealerRow(cust);
|
const row = useDealerRow(cust);
|
||||||
const materials = useMemo(() => parseJson<Opt[]>(row.data?.materials_json, []), [row.data]);
|
const materials = useMemo(() => parseJson<Opt[]>(row.data?.materials_json, []), [row.data]);
|
||||||
const shipTos = useMemo(() => parseJson<Opt[]>(row.data?.ship_tos_json, []), [row.data]);
|
const shipTos = useMemo(() => parseJson<Opt[]>(row.data?.ship_tos_json, []), [row.data]);
|
||||||
|
const plants = useMemo(() => parseJson<Opt[]>(row.data?.plants_json, []), [row.data]);
|
||||||
const asOf = d.data?.as_of ?? '';
|
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 [material, setMaterial] = useState('');
|
||||||
const [qty, setQty] = useState(20);
|
const [qty, setQty] = useState('20');
|
||||||
const [shipTo, setShipTo] = useState('');
|
const [shipTo, setShipTo] = useState('');
|
||||||
|
const [plant, setPlant] = useState('');
|
||||||
const [when, setWhen] = useState('');
|
const [when, setWhen] = useState('');
|
||||||
const [more, setMore] = useState(false);
|
const [notes, setNotes] = useState('');
|
||||||
const [busy, setBusy] = useState<'now' | 'ai' | null>(null);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
const mat = material || String(row.data?.default_material ?? materials[0]?.code ?? '');
|
const mat = material || String(row.data?.default_material ?? materials[0]?.code ?? '');
|
||||||
const st = shipTo || String(row.data?.default_ship_to ?? shipTos[0]?.code ?? '');
|
const st = shipTo || String(row.data?.default_ship_to ?? shipTos[0]?.code ?? '');
|
||||||
const pl = String(row.data?.default_plant ?? '');
|
const pl = plant || String(row.data?.default_plant ?? plants[0]?.code ?? '');
|
||||||
const dt = when || plusDays(asOf, 3);
|
const dt = when || defaultDate;
|
||||||
const isPremium = materials.find((m) => m.code === mat)?.premium ?? false;
|
const isPremium = materials.find((m) => m.code === mat)?.premium ?? false;
|
||||||
|
const q = num(qty);
|
||||||
|
|
||||||
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 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, 350);
|
const sim = useSimulate(cust, hypo);
|
||||||
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 nextSlab = useMemo(() => {
|
||||||
const c = rows.filter((r) => r.basis === 'MONTHLY_VOLUME' && r.mt_to_next_slab != null && num(r.mt_to_next_slab) > 0);
|
const cands = (sim.data ?? []).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;
|
return cands.length ? Math.min(...cands.map((r) => num(r.mt_to_next_slab))) : null;
|
||||||
}, [rows]);
|
}, [sim.data]);
|
||||||
|
|
||||||
async function go(mode: 'now' | 'ai') {
|
async function save() {
|
||||||
if (!cust || qty <= 0 || !mat) { setErr('Pick a product and a quantity'); return; }
|
if (!cust || q <= 0 || !mat) { setErr('Pick a material and a quantity'); return; }
|
||||||
setBusy(mode); setErr(null);
|
setBusy(true); 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 {
|
try {
|
||||||
const res = mode === 'now' ? await placeOneTap(client, o) : await placeDraft(client, o);
|
const res = await client.startInstance(WF.ORDER, ACT.PLACE, {
|
||||||
navigate(`/d/orders/${res.instance_id}${mode === 'now' ? '?placed=1' : '?asked=1'}`);
|
customer_no__place: cust, dealer_name__place: d.data?.snapshot?.name ?? '', ship_to_code__place: st, plant_code__place: pl,
|
||||||
} catch (e) { setErr((e as { message?: string })?.message ?? 'Could not save the order'); } finally { setBusy(null); }
|
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); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||||
if ((row.loading && !row.data) || (d.loading && !d.data)) return <Spinner />;
|
if ((row.loading && !row.data) || (d.loading && !d.data)) return <Spinner />;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-5">
|
||||||
<h1 className="text-lg font-semibold text-ink">Place an order</h1>
|
<Card className="xl:col-span-2" title="Place an order" subtitle={`${d.data?.snapshot?.name ?? cust} · prices and schemes as of ${date(asOf)}`}>
|
||||||
{err && <ErrorNote>{err}</ErrorNote>}
|
<div className="space-y-3 p-4">
|
||||||
|
{err && <ErrorNote>{err}</ErrorNote>}
|
||||||
<div>
|
<div><Label required>Material</Label><Select value={mat} onChange={setMaterial} options={materials.map((m) => ({ value: m.code, label: `${m.name}${m.premium ? ' · premium' : ''}` }))} /></div>
|
||||||
<Label>Product</Label>
|
<div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<Label required>Quantity (MT)</Label>
|
||||||
{materials.map((m) => (
|
<Input type="number" step="1" value={qty} onChange={setQty} />
|
||||||
<button key={m.code} onClick={() => setMaterial(m.code)} className={`rounded-full px-3 py-1.5 text-[13px] font-semibold ring-1 ring-inset ${m.code === mat ? 'bg-brand text-brand-fg ring-brand' : 'bg-panel text-ink ring-line'}`}>{m.name}{m.premium ? ' ★' : ''}</button>
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
))}
|
{[10, 20, 30, 50].map((n) => <Button key={n} size="sm" onClick={() => setQty(String(n))}>{n} MT</Button>)}
|
||||||
|
{nextSlab != null && <Button size="sm" kind="accent" onClick={() => setQty(String(Math.ceil(nextSlab)))}>Next slab · {mt(Math.ceil(nextSlab), 0)}</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label>Ship to</Label><Select value={st} onChange={setShipTo} options={shipTos.map((s) => ({ value: s.code, label: `${s.name} (${s.cluster})` }))} /></div>
|
||||||
|
<div><Label>Plant</Label><Select value={pl} onChange={setPlant} options={plants.map((p) => ({ value: p.code, label: `${p.name} · ${p.type}` }))} /></div>
|
||||||
|
<div><Label>Requested delivery</Label><Input type="date" value={dt} onChange={setWhen} /></div>
|
||||||
|
<div><Label>Notes</Label><Textarea value={notes} onChange={setNotes} rows={2} /></div>
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-line pt-3">
|
||||||
|
<Button kind="primary" onClick={save} busy={busy}><Sparkles className="h-4 w-4" />Save draft and get advice</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-faint">Saving creates a draft. The Scheme Advisor reads it within a minute or two and writes what to change, if anything. You confirm or cancel from My orders.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
<Card className="xl:col-span-3" title="What this order earns" subtitle="The scheme engine, live. Change the quantity and watch the slabs move.">
|
||||||
<div>
|
<div className="space-y-4 p-4">
|
||||||
<Label>Quantity</Label>
|
<SimSummary rows={sim.data} hasHypo={!!hypo} />
|
||||||
<div className="flex items-center gap-2">
|
<div className="-mx-4 border-t border-line">
|
||||||
<button aria-label="Less" onClick={() => setQty((q) => Math.max(1, q - 5))} className="rounded-lg border border-line bg-panel p-3 text-ink"><Minus className="h-5 w-5" /></button>
|
<SimTable rows={sim.data} hasHypo={!!hypo} loading={sim.loading} error={sim.error} />
|
||||||
<div className="tnum flex-1 rounded-lg border border-line bg-panel py-2 text-center text-2xl font-semibold text-ink">{qty} <span className="text-base font-medium text-faint">MT</span></div>
|
</div>
|
||||||
<button aria-label="More" onClick={() => setQty((q) => q + 5)} className="rounded-lg border border-line bg-panel p-3 text-ink"><Plus className="h-5 w-5" /></button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
</Card>
|
||||||
{[10, 20, 30, 50].map((n) => <Button key={n} size="sm" onClick={() => setQty(n)}>{n} MT</Button>)}
|
|
||||||
{nextSlab != null && nextSlab !== qty && <Button size="sm" kind="accent" onClick={() => setQty(qty + nextSlab)}>+{nextSlab} MT reaches the next slab</Button>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`rounded-xl border p-4 ${inc > 0 ? 'border-accent-edge bg-accent-soft' : inc < 0 ? 'border-bad-edge bg-bad-soft' : 'border-line bg-panel'}`}>
|
|
||||||
<div className="text-[11px] font-semibold tracking-wide text-muted uppercase">This order earns</div>
|
|
||||||
<div className={`tnum hero-num mt-1 font-semibold ${inc > 0 ? 'text-accent' : inc < 0 ? 'text-bad' : 'text-ink'}`}>{sim.loading && !sim.data ? '…' : inr(inc, { sign: true })}</div>
|
|
||||||
<ul className="mt-2 space-y-0.5 text-[13px] text-muted">
|
|
||||||
{movers.map((r) => <li key={r.component_id} className="flex justify-between gap-3"><span className="truncate">{r.scheme_name} · {r.component_name}</span><span className="tnum shrink-0 font-semibold text-ink">{inr(r.incremental, { sign: true })}</span></li>)}
|
|
||||||
{!sim.loading && movers.length === 0 && <li>No slab moves with this quantity.</li>}
|
|
||||||
</ul>
|
|
||||||
<button onClick={() => setMore((m) => !m)} className="mt-2 inline-flex items-center gap-1 text-[13px] font-semibold text-muted" aria-expanded={more}>All schemes<ChevronDown className={`h-4 w-4 transition-transform ${more ? 'rotate-180' : ''}`} /></button>
|
|
||||||
{more && <div className="-mx-4 mt-2 border-t border-line"><SimTable rows={sim.data} hasHypo loading={sim.loading} error={sim.error} /></div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shipTos.length > 1 && <div><Label>Deliver to</Label><Select value={st} onChange={setShipTo} options={shipTos.map((s) => ({ value: s.code, label: s.name }))} /></div>}
|
|
||||||
<div><Label>Delivery date</Label><Input type="date" value={dt} onChange={setWhen} /></div>
|
|
||||||
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<Button kind="primary" size="lg" className="w-full" onClick={() => go('now')} busy={busy === 'now'} disabled={busy === 'ai'}><Package className="h-5 w-5" />Place order · {mt(qty, 0)}</Button>
|
|
||||||
<Button kind="accent" size="lg" className="w-full" onClick={() => go('ai')} busy={busy === 'ai'} disabled={busy === 'now'}><Sparkles className="h-5 w-5" />Ask the Advisor first</Button>
|
|
||||||
<p className="text-center text-[11px] text-faint">Place order confirms it now. Ask the Advisor saves a draft; the AI reads your history and the live schemes and replies in about a minute.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,113 +1,119 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { CheckCircle2, ChevronRight, Sparkles } from 'lucide-react';
|
import { Sparkles } from 'lucide-react';
|
||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { ACT, RV, WF } from '../../api/config';
|
import { ACT, RV, WF } from '../../api/config';
|
||||||
import type { Row } from '../../api/types';
|
import type { Row } from '../../api/types';
|
||||||
import { ActivityForm } from '../../components/ActivityForm';
|
import { ActivityForm } from '../../components/ActivityForm';
|
||||||
import { Button, ErrorNote, Spinner } from '../../components/core';
|
import { Button, Card, Empty, ErrorNote, Facts, Spinner } from '../../components/core';
|
||||||
import { InstanceTrace, KindBadge, Prose, StateBadge } from '../../components/widgets';
|
import { DataTable, InstanceTrace, KindBadge, RecCard, StateBadge } from '../../components/widgets';
|
||||||
import { date, inr, mt, num } from '../../format';
|
import { date, inr, mt, num, pct } from '../../format';
|
||||||
|
|
||||||
function useOrders(customerNo: string | null | undefined, refetchMs?: number) {
|
function useOrders(customerNo: string | null | undefined, refetchMs?: number) {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
return useQuery(() => client.rows(RV.ORDERS, { limit: 100, sortBy: 'instance_id', sortDir: 'desc', filters: customerNo ? [{ field_key: 'customer_no', value: customerNo, data_type: 'string' }] : [] }), [customerNo], true, refetchMs);
|
return useQuery(() => client.rows(RV.ORDERS, { limit: 100, sortBy: 'instance_id', sortDir: 'desc', filters: customerNo ? [{ field_key: 'customer_no', value: customerNo, data_type: 'string' }] : [] }), [customerNo], true, refetchMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** My orders: one line each, newest first. */
|
|
||||||
export function DealerOrders() {
|
export function DealerOrders() {
|
||||||
const { portal } = useZino();
|
const { portal } = useZino();
|
||||||
|
const navigate = useNavigate();
|
||||||
const q = useOrders(portal?.customer_no, 15000);
|
const q = useOrders(portal?.customer_no, 15000);
|
||||||
const rows = (q.data ?? []) as Row[];
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between"><h1 className="text-lg font-semibold text-ink">My orders</h1><Link to="/d/order"><Button kind="primary" size="sm">New order</Button></Link></div>
|
<div className="flex items-center justify-between"><h1 className="text-xl font-semibold text-ink">My orders</h1><Link to="/d/order/new"><Button kind="primary">Place order</Button></Link></div>
|
||||||
{q.loading && !q.data && <Spinner />}
|
<Card>
|
||||||
{q.error && <ErrorNote>{q.error}</ErrorNote>}
|
<DataTable rows={q.data} loading={q.loading} error={q.error} onRow={(r) => navigate(`/d/orders/${r.instance_id}`)} rowKey={(r) => String(r.instance_id)} empty="No orders yet."
|
||||||
<ul className="divide-y divide-line rounded-lg border border-line bg-panel">
|
cols={[
|
||||||
{rows.length === 0 && !q.loading && <li className="px-3 py-8 text-center text-[13px] text-faint">No orders yet. Place one from Today or Order.</li>}
|
{ key: 'instance_id', label: '#' },
|
||||||
{rows.map((r) => {
|
{ key: 'material_code', label: 'Material' },
|
||||||
const inc = num(r.incremental_payout);
|
{ key: 'qty_mt', label: 'Keyed', align: 'right', render: (r) => mt(r.qty_mt) },
|
||||||
const final = r.final_qty_mt ? num(r.final_qty_mt) : num(r.qty_mt);
|
{ key: 'requested_date', label: 'For', render: (r) => date(r.requested_date) },
|
||||||
return (
|
{ key: 'current_state_name', label: 'Status', render: (r) => <StateBadge state={r.current_state_name} /> },
|
||||||
<li key={String(r.instance_id)}>
|
{ key: 'advice_kind', label: 'Advice', render: (r) => (r.advice_kind ? <KindBadge kind={r.advice_kind} /> : <span className="text-faint">—</span>) },
|
||||||
<Link to={`/d/orders/${String(r.instance_id)}`} className="flex items-center gap-3 px-3 py-3">
|
{ key: 'advice_headline', label: 'Headline', className: 'max-w-md', render: (r) => <span className="line-clamp-2 text-xs text-muted">{String(r.advice_headline ?? '')}</span> },
|
||||||
<div className="min-w-0 flex-1">
|
{ key: 'incremental_payout', label: 'Worth', align: 'right', render: (r) => (r.incremental_payout != null ? <span className="font-semibold text-accent">{inr(r.incremental_payout, { sign: true })}</span> : '—') },
|
||||||
<div className="flex items-center gap-2"><span className="tnum text-[15px] font-semibold text-ink">{mt(final, 0)}</span><span className="truncate text-[13px] text-muted">{String(r.material_code ?? '')}</span><StateBadge state={r.current_state_name} /></div>
|
{ key: 'final_qty_mt', label: 'Final', align: 'right', render: (r) => (r.final_qty_mt ? mt(r.final_qty_mt) : '—') },
|
||||||
<div className="mt-0.5 truncate text-[11px] text-faint">#{String(r.instance_id)} · for {date(r.requested_date)}{r.advice_kind ? ` · ${String(r.advice_headline ?? '')}` : ''}</div>
|
]} />
|
||||||
</div>
|
</Card>
|
||||||
{inc > 0 && <span className="tnum shrink-0 text-[13px] font-semibold text-accent">{inr(inc, { sign: true })}</span>}
|
|
||||||
<ChevronRight className="h-4 w-4 shrink-0 text-faint" />
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One order: the status, the Advisor's answer if there is one, and the buttons that matter in this state. */
|
|
||||||
export function OrderDetail() {
|
export function OrderDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const [sp] = useSearchParams();
|
|
||||||
const { client, user } = useZino();
|
const { client, user } = useZino();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const supplier = (user?.roles ?? []).some((r) => ['asm', 'rso_manager', 'ho_scheme_admin'].includes(r));
|
const supplier = (user?.roles ?? []).some((r) => ['asm', 'rso_manager', 'ho_scheme_admin'].includes(r));
|
||||||
const q = useQuery(() => client.rows(RV.ORDERS, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] }).then((rows) => rows[0] ?? null), [id], true, 8000);
|
const q = useQuery(() => client.rows(RV.ORDERS, { limit: 1, filters: [{ field_key: 'instance_id', value: String(id), data_type: 'number' }] }).then((rows) => rows[0] ?? null), [id], true, 8000);
|
||||||
|
const recs = useQuery(() => client.rows(RV.RECOMMENDATIONS, { limit: 50, sortBy: 'rec_id', sortDir: 'desc', filters: [{ field_key: 'source_instance_id', value: String(id), data_type: 'number' }] }), [id], true, 15000);
|
||||||
const [form, setForm] = useState<null | { act: string; title: string; seed?: Record<string, unknown>; prefill?: Record<string, unknown>; danger?: boolean; label?: string }>(null);
|
const [form, setForm] = useState<null | { act: string; title: string; seed?: Record<string, unknown>; prefill?: Record<string, unknown>; danger?: boolean; label?: string }>(null);
|
||||||
const o = q.data;
|
const o = q.data;
|
||||||
const state = String(o?.current_state_name ?? '');
|
const state = String(o?.current_state_name ?? '');
|
||||||
|
const myRecs = useMemo(() => (recs.data ?? []) as Row[], [recs.data]);
|
||||||
if (q.loading && !o) return <Spinner />;
|
if (q.loading && !o) return <Spinner />;
|
||||||
if (q.error) return <ErrorNote>{q.error}</ErrorNote>;
|
if (q.error) return <ErrorNote>{q.error}</ErrorNote>;
|
||||||
if (!o) return <ErrorNote>Order {id} not found.</ErrorNote>;
|
if (!o) return <Empty>Order {id} not found.</Empty>;
|
||||||
const suggested = o.suggested_qty_mt != null && num(o.suggested_qty_mt) > 0 ? num(o.suggested_qty_mt) : null;
|
const suggested = o.suggested_qty_mt != null && num(o.suggested_qty_mt) > 0 ? num(o.suggested_qty_mt) : null;
|
||||||
const canDecide = state === 'Draft' || state === 'Advised';
|
const canDecide = state === 'Draft' || state === 'Advised';
|
||||||
const inc = num(o.incremental_payout);
|
const inc = num(o.incremental_payout);
|
||||||
const placed = sp.get('placed') === '1';
|
|
||||||
const asked = sp.get('asked') === '1';
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
{placed && state === 'Confirmed' && <div className="flex items-center gap-2 rounded-lg border border-good-edge bg-good-soft px-3 py-2.5 text-[13px] font-semibold text-good"><CheckCircle2 className="h-4 w-4" />Order placed. Meru has it as a sales order.</div>}
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<h1 className="text-xl font-semibold text-ink">Order #{String(o.instance_id)}</h1>
|
||||||
<div><h1 className="tnum text-lg font-semibold text-ink">{mt(o.final_qty_mt ? o.final_qty_mt : o.qty_mt, 0)} {String(o.material_code ?? '')}</h1><div className="text-[11px] text-faint">#{String(o.instance_id)} · for {date(o.requested_date)} · to {String(o.final_ship_to_code || o.ship_to_code || '—')}</div></div>
|
<StateBadge state={state} />
|
||||||
<span className="ml-auto"><StateBadge state={state} /></span>
|
{state === 'Draft' && <span className="text-xs text-ai"><Sparkles className="mr-1 inline h-3 w-3" />the Advisor is reading this order…</span>}
|
||||||
</div>
|
<div className="ml-auto flex flex-wrap gap-2">
|
||||||
|
{canDecide && suggested != null && suggested !== num(o.qty_mt) && (
|
||||||
{state === 'Draft' && (
|
<Button kind="accent" onClick={() => setForm({ act: ACT.CONFIRM, title: 'Confirm as advised', label: `Confirm ${mt(suggested, 0)}`, seed: { final_material_code: o.material_code, final_ship_to_code: o.suggested_ship_to || o.ship_to_code, advice_accepted: true }, prefill: { final_qty_mt: suggested } })}>Take the advice · {mt(suggested, 0)}</Button>
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-ai-edge bg-ai-soft px-3 py-2.5 text-[13px] text-ai"><Sparkles className="h-4 w-4 shrink-0 animate-pulse" />{asked ? 'The Advisor is reading your history and the live schemes. About a minute.' : 'The Advisor is reading this order.'}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{Boolean(o.advice_kind) && (
|
|
||||||
<div className="rounded-xl border border-ai-edge bg-ai-soft p-4">
|
|
||||||
<div className="flex items-center justify-between gap-3"><KindBadge kind={o.advice_kind} />{inc !== 0 && <span className={`tnum text-xl font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</span>}</div>
|
|
||||||
<p className="mt-2 text-[15px] leading-snug font-semibold text-ink">{String(o.advice_headline ?? '')}</p>
|
|
||||||
{o.advice_rationale ? <Prose text={o.advice_rationale} className="mt-2 text-[13px] text-muted" /> : null}
|
|
||||||
{o.advice_deadline ? <div className="mt-2 text-[11px] text-faint">Act by {date(o.advice_deadline)}{suggested != null ? ` · suggested total ${mt(suggested, 0)}` : ''}</div> : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Boolean(o.advice_reason) && <ErrorNote>{String(o.advice_reason)}</ErrorNote>}
|
|
||||||
|
|
||||||
{canDecide && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{suggested != null && suggested !== num(o.qty_mt) && (
|
|
||||||
<Button kind="primary" size="lg" className="w-full" onClick={() => setForm({ act: ACT.CONFIRM, title: `Confirm ${mt(suggested, 0)}`, label: `Confirm ${mt(suggested, 0)}`, seed: { final_qty_mt: suggested, final_material_code: o.material_code, final_ship_to_code: o.suggested_ship_to || o.ship_to_code, advice_accepted: true } })}>Take the advice · {mt(suggested, 0)}</Button>
|
|
||||||
)}
|
)}
|
||||||
<Button kind={suggested != null && suggested !== num(o.qty_mt) ? 'secondary' : 'primary'} size="lg" className="w-full" onClick={() => setForm({ act: ACT.CONFIRM, title: `Confirm ${mt(o.qty_mt, 0)}`, label: `Confirm ${mt(o.qty_mt, 0)}`, seed: { final_qty_mt: num(o.qty_mt), final_material_code: o.material_code, final_ship_to_code: o.ship_to_code, advice_accepted: false } })}>Keep {mt(o.qty_mt, 0)} as keyed</Button>
|
{canDecide && <Button kind="primary" onClick={() => setForm({ act: ACT.CONFIRM, title: 'Confirm order', label: 'Confirm', seed: { final_material_code: o.material_code, final_ship_to_code: o.ship_to_code, advice_accepted: false }, prefill: { final_qty_mt: num(o.qty_mt) } })}>Confirm as keyed</Button>}
|
||||||
<Button kind="ghost" className="w-full" onClick={() => setForm({ act: ACT.CANCEL, title: 'Cancel order', label: 'Cancel order', danger: true })}>Cancel this order</Button>
|
{canDecide && <Button kind="danger" onClick={() => setForm({ act: ACT.CANCEL, title: 'Cancel order', label: 'Cancel order', danger: true })}>Cancel</Button>}
|
||||||
|
{supplier && state === 'Confirmed' && <Button onClick={() => setForm({ act: ACT.DISPATCH, title: 'Mark dispatched', label: 'Dispatched' })}>Mark dispatched</Button>}
|
||||||
|
{supplier && state === 'Dispatched' && <Button onClick={() => setForm({ act: ACT.INVOICE, title: 'Mark invoiced', label: 'Invoiced' })}>Mark invoiced</Button>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{supplier && state === 'Confirmed' && <Button className="w-full" onClick={() => setForm({ act: ACT.DISPATCH, title: 'Mark dispatched', label: 'Dispatched' })}>Mark dispatched</Button>}
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-3">
|
||||||
{supplier && state === 'Dispatched' && <Button className="w-full" onClick={() => setForm({ act: ACT.INVOICE, title: 'Mark invoiced', label: 'Invoiced' })}>Mark invoiced</Button>}
|
<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</span>} subtitle={o.advice_kind ? `Advice recorded ${o.advice_deadline ? `· act by ${date(o.advice_deadline)}` : ''}${o.advice_confidence ? ` · confidence ${pct(num(o.advice_confidence) * 100, 0)}` : ''}` : 'Waiting for the Advisor to read the draft.'}>
|
||||||
<details className="rounded-lg border border-line bg-panel">
|
<div className="space-y-4 p-4">
|
||||||
<summary className="cursor-pointer px-3 py-2.5 text-[13px] font-semibold text-ink">History</summary>
|
{Boolean(o.advice_kind) && (
|
||||||
<div className="border-t border-line"><InstanceTrace instanceId={String(id)} bare /></div>
|
<div className="rounded-lg border border-ai-edge bg-ai-soft p-4">
|
||||||
</details>
|
<div className="flex flex-wrap items-center gap-2"><KindBadge kind={o.advice_kind} />{o.suggested_scheme ? <span className="text-xs text-faint">{String(o.suggested_scheme)}</span> : null}{inc !== 0 && <span className={`tnum ml-auto text-lg font-semibold ${inc > 0 ? 'text-accent' : 'text-bad'}`}>{inr(inc, { sign: true })}</span>}</div>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-ink">{String(o.advice_headline ?? '')}</p>
|
||||||
|
<p className="mt-2 text-sm whitespace-pre-line text-ink">{String(o.advice_rationale ?? '')}</p>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-x-6 gap-y-2 text-xs sm:grid-cols-4">
|
||||||
|
<div><div className="text-faint">Keyed</div><div className="tnum text-ink">{mt(o.qty_mt)} · {inr(o.payout_now)}</div></div>
|
||||||
|
<div><div className="text-faint">Suggested</div><div className="tnum text-ink">{suggested != null ? mt(suggested) : '—'}{o.suggested_pp_mt ? ` (${mt(o.suggested_pp_mt)} premium)` : ''} · {inr(o.payout_if)}</div></div>
|
||||||
|
<div><div className="text-faint">Ship to</div><div className="text-ink">{String(o.suggested_ship_to || o.ship_to_code || '—')}</div></div>
|
||||||
|
<div><div className="text-faint">Act by</div><div className="text-ink">{date(o.advice_deadline)}</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Boolean(o.advice_reason) && <ErrorNote>{String(o.advice_reason)}</ErrorNote>}
|
||||||
|
{myRecs.map((r) => <RecCard key={String(r.rec_id)} rec={r} />)}
|
||||||
|
{!o.advice_kind && myRecs.length === 0 && <Spinner label="The Advisor is working — this refreshes on its own." />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<InstanceTrace instanceId={String(id)} />
|
||||||
|
</div>
|
||||||
|
<Card title="The order">
|
||||||
|
<div className="p-4">
|
||||||
|
<Facts cols={1} rows={[
|
||||||
|
{ label: 'Dealer', value: `${String(o.dealer_name ?? '')} (${String(o.customer_no ?? '')})` },
|
||||||
|
{ label: 'Material', value: String(o.material_code ?? '') },
|
||||||
|
{ label: 'Quantity keyed', value: mt(o.qty_mt) },
|
||||||
|
{ label: 'Ship to', value: String(o.ship_to_code ?? '—') },
|
||||||
|
{ label: 'Plant', value: String(o.plant_code ?? '—') },
|
||||||
|
{ label: 'Requested delivery', value: date(o.requested_date) },
|
||||||
|
{ label: 'Final quantity', value: o.final_qty_mt ? `${mt(o.final_qty_mt)}${o.advice_accepted ? ' · advice taken' : ''}` : '—' },
|
||||||
|
{ label: 'Notes', value: String(o.notes ?? ''), wide: true },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
{form && (
|
{form && (
|
||||||
<ActivityForm open onClose={() => setForm(null)} workflow={WF.ORDER} activityUid={form.act} instanceId={String(id)} title={form.title} submitLabel={form.label} danger={form.danger}
|
<ActivityForm open onClose={() => setForm(null)} workflow={WF.ORDER} activityUid={form.act} instanceId={String(id)} title={form.title} submitLabel={form.label} danger={form.danger}
|
||||||
seed={form.seed} prefill={form.prefill} hiddenKeys={['advice_accepted', 'final_material_code', 'final_ship_to_code']} onDone={() => { q.refetch(); if (form.act === ACT.CANCEL) navigate('/d/orders'); }} />
|
seed={form.seed} prefill={form.prefill} hiddenKeys={['advice_accepted']} onDone={() => { q.refetch(); if (form.act === ACT.CANCEL) navigate('/d/orders'); }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
import { useZino } from '../../api/provider';
|
import { useZino } from '../../api/provider';
|
||||||
import { ErrorNote } from '../../components/core';
|
import { Card, ErrorNote } from '../../components/core';
|
||||||
import { SimSummary, SimTable, useSimulate } from '../../components/widgets';
|
import { SimSummary, SimTable, useSimulate } from '../../components/widgets';
|
||||||
|
|
||||||
/** Every live scheme component, what it pays now, and how far the next slab is. */
|
|
||||||
export function DealerSchemes() {
|
export function DealerSchemes() {
|
||||||
const { portal } = useZino();
|
const { portal } = useZino();
|
||||||
const cust = portal?.customer_no;
|
const cust = portal?.customer_no;
|
||||||
const sim = useSimulate(cust, null);
|
const sim = useSimulate(cust, null);
|
||||||
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
if (!cust) return <ErrorNote>This login is not mapped to a dealer.</ErrorNote>;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<h1 className="text-lg font-semibold text-ink">My schemes</h1>
|
<h1 className="text-xl font-semibold text-ink">My schemes</h1>
|
||||||
<SimSummary rows={sim.data} hasHypo={false} />
|
<p className="text-sm text-muted">Every scheme component you are eligible for in the current period: what it pays as things stand, the gate if nothing is paid, and the distance to the next slab or growth bracket.</p>
|
||||||
<div className="rounded-lg border border-line bg-panel"><SimTable rows={sim.data} hasHypo={false} loading={sim.loading} error={sim.error} /></div>
|
<Card>
|
||||||
|
<div className="p-4"><SimSummary rows={sim.data} hasHypo={false} /></div>
|
||||||
|
<div className="border-t border-line"><SimTable rows={sim.data} hasHypo={false} loading={sim.loading} error={sim.error} /></div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useZino } from '../../api/provider';
|
import { useQuery, useZino } from '../../api/provider';
|
||||||
import { ACT, RV, WF } from '../../api/config';
|
import { RV } from '../../api/config';
|
||||||
import type { RecRow, Row, Snapshot, Standing } from '../../api/types';
|
import type { Row, Snapshot, Standing } from '../../api/types';
|
||||||
import { parseJson } from '../../format';
|
import { parseJson } from '../../format';
|
||||||
|
|
||||||
/** The signed-in dealer's snapshot + cluster standing (one engine call). */
|
/** The signed-in dealer's snapshot + cluster standing (one engine call). */
|
||||||
@ -25,44 +25,3 @@ export function useDealerRow(customerNo: string | null | undefined) {
|
|||||||
[customerNo], !!customerNo,
|
[customerNo], !!customerNo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The engine's shortlist: the few actions worth taking this period, ranked, each already simulated. ~1 s. */
|
|
||||||
export function useRecommend(customerNo: string | null | undefined) {
|
|
||||||
const { client } = useZino();
|
|
||||||
return useQuery(
|
|
||||||
() => client.rows(RV.RECOMMEND, { limit: 10, params: { customer_no: customerNo } }).then((r) => (r as unknown as RecRow[]).sort((a, b) => Number(a.rank) - Number(b.rank))),
|
|
||||||
[customerNo], !!customerNo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Opt = { code: string; name: string; premium?: boolean; cluster?: string; is_default?: boolean; type?: string };
|
|
||||||
|
|
||||||
export interface OneTapOrder {
|
|
||||||
customer_no: string; dealer_name: string; ship_to_code: string; plant_code: string; material_code: string; qty_mt: number; requested_date: string; as_of: string;
|
|
||||||
advice_kind?: string | null; advice_headline?: string | null; incremental_payout?: number | null; suggested_scheme?: string | null; advice_deadline?: string | null; advice_accepted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Place an order that goes straight to Confirmed and into the estate — no AI turn. */
|
|
||||||
export function placeOneTap(client: ReturnType<typeof useZino>['client'], o: OneTapOrder) {
|
|
||||||
return client.startInstance(WF.ORDER, ACT.PLACE_ADVISED, {
|
|
||||||
customer_no__advised: o.customer_no, dealer_name__advised: o.dealer_name, ship_to_code__advised: o.ship_to_code, plant_code__advised: o.plant_code,
|
|
||||||
material_code__advised: o.material_code, qty_mt__advised: o.qty_mt, requested_date__advised: o.requested_date, as_of__advised: o.as_of,
|
|
||||||
...(o.advice_kind ? { advice_kind__advised: o.advice_kind } : {}), advice_headline__advised: o.advice_headline ?? '', incremental_payout__advised: o.incremental_payout ?? 0,
|
|
||||||
suggested_scheme__advised: o.suggested_scheme ?? '', ...(o.advice_deadline ? { advice_deadline__advised: o.advice_deadline } : {}), advice_accepted__advised: o.advice_accepted,
|
|
||||||
final_qty_mt__advised: o.qty_mt, final_material_code__advised: o.material_code, final_ship_to_code__advised: o.ship_to_code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save a draft for the Scheme Advisor to read (Draft → the AI writes advice → Advised). */
|
|
||||||
export function placeDraft(client: ReturnType<typeof useZino>['client'], o: OneTapOrder, notes = '') {
|
|
||||||
return client.startInstance(WF.ORDER, ACT.PLACE, {
|
|
||||||
customer_no__place: o.customer_no, dealer_name__place: o.dealer_name, ship_to_code__place: o.ship_to_code, plant_code__place: o.plant_code,
|
|
||||||
material_code__place: o.material_code, qty_mt__place: o.qty_mt, requested_date__place: o.requested_date, as_of__place: o.as_of, notes__place: notes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Three days out, ISO. */
|
|
||||||
export function plusDays(iso: string, days: number): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(new Date(iso).getTime() + days * 86400000).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useQuery, useZino } from '../../api/provider';
|
|||||||
import { ACT, RV, WF } from '../../api/config';
|
import { ACT, RV, WF } from '../../api/config';
|
||||||
import { ActivityForm } from '../../components/ActivityForm';
|
import { ActivityForm } from '../../components/ActivityForm';
|
||||||
import { Button, Card, Empty, ErrorNote, Facts, Kpi, Spinner } from '../../components/core';
|
import { Button, Card, Empty, ErrorNote, Facts, Kpi, Spinner } from '../../components/core';
|
||||||
import { DataTable, InstanceTrace, KindBadge, Prose, RecCard, StateBadge } from '../../components/widgets';
|
import { DataTable, InstanceTrace, KindBadge, RecCard, StateBadge } from '../../components/widgets';
|
||||||
import { date, inr, mt, num, pct } from '../../format';
|
import { date, inr, mt, num, pct } from '../../format';
|
||||||
import { useScope } from './scope';
|
import { useScope } from './scope';
|
||||||
|
|
||||||
@ -68,7 +68,7 @@ export function AdvisoryDetail() {
|
|||||||
<div className="space-y-5 xl:col-span-2">
|
<div className="space-y-5 xl:col-span-2">
|
||||||
{isSweep ? (
|
{isSweep ? (
|
||||||
<>
|
<>
|
||||||
<Card title="Commentary"><div className="p-4 text-sm text-ink">{a.commentary ? <Prose text={a.commentary} /> : 'Waiting…'}</div></Card>
|
<Card title="Commentary"><p className="p-4 text-sm whitespace-pre-line text-ink">{String(a.commentary ?? '') || 'Waiting…'}</p></Card>
|
||||||
<Card title="Nudge list" subtitle="One line per dealer, ranked by what the nudge is worth. Send as is."><pre className="overflow-x-auto p-4 font-sans text-sm whitespace-pre-wrap text-ink">{String(a.nudge_list ?? '')}</pre></Card>
|
<Card title="Nudge list" subtitle="One line per dealer, ranked by what the nudge is worth. Send as is."><pre className="overflow-x-auto p-4 font-sans text-sm whitespace-pre-wrap text-ink">{String(a.nudge_list ?? '')}</pre></Card>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -78,7 +78,7 @@ export function AdvisoryDetail() {
|
|||||||
<div className="rounded-lg border border-ai-edge bg-ai-soft p-4">
|
<div className="rounded-lg border border-ai-edge bg-ai-soft p-4">
|
||||||
<div className="flex flex-wrap items-center gap-2"><KindBadge kind={a.advice_kind} />{a.suggested_scheme ? <span className="text-xs text-faint">{String(a.suggested_scheme)}</span> : null}{a.incremental_payout != null && <span className="tnum ml-auto text-lg font-semibold text-accent">{inr(a.incremental_payout, { sign: true })}</span>}</div>
|
<div className="flex flex-wrap items-center gap-2"><KindBadge kind={a.advice_kind} />{a.suggested_scheme ? <span className="text-xs text-faint">{String(a.suggested_scheme)}</span> : null}{a.incremental_payout != null && <span className="tnum ml-auto text-lg font-semibold text-accent">{inr(a.incremental_payout, { sign: true })}</span>}</div>
|
||||||
<p className="mt-2 text-sm font-semibold text-ink">{String(a.advice_headline ?? '')}</p>
|
<p className="mt-2 text-sm font-semibold text-ink">{String(a.advice_headline ?? '')}</p>
|
||||||
<Prose text={a.advice_rationale} className="mt-2 text-sm text-ink" />
|
<p className="mt-2 text-sm whitespace-pre-line text-ink">{String(a.advice_rationale ?? '')}</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-6 text-xs text-faint">{a.suggested_qty_mt ? <span>Add <b className="text-ink">{mt(a.suggested_qty_mt)}</b></span> : null}{a.suggested_pp_mt ? <span>Premium <b className="text-ink">{mt(a.suggested_pp_mt)}</b></span> : null}{a.advice_deadline ? <span>By <b className="text-ink">{date(a.advice_deadline)}</b></span> : null}{a.advice_confidence ? <span>Confidence {pct(num(a.advice_confidence) * 100, 0)}</span> : null}</div>
|
<div className="mt-3 flex flex-wrap gap-x-6 text-xs text-faint">{a.suggested_qty_mt ? <span>Add <b className="text-ink">{mt(a.suggested_qty_mt)}</b></span> : null}{a.suggested_pp_mt ? <span>Premium <b className="text-ink">{mt(a.suggested_pp_mt)}</b></span> : null}{a.advice_deadline ? <span>By <b className="text-ink">{date(a.advice_deadline)}</b></span> : null}{a.advice_confidence ? <span>Confidence {pct(num(a.advice_confidence) * 100, 0)}</span> : null}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,15 +8,6 @@ import { DataTable, StateBadge } from '../../components/widgets';
|
|||||||
import { date, inr, mt } from '../../format';
|
import { date, inr, mt } from '../../format';
|
||||||
import { useScope } from './scope';
|
import { useScope } from './scope';
|
||||||
|
|
||||||
/** The previous calendar month, the natural default for a monthly run. */
|
|
||||||
function lastMonth(): { period_from: string; period_to: string } {
|
|
||||||
const now = new Date();
|
|
||||||
const from = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
||||||
const to = new Date(now.getFullYear(), now.getMonth(), 0);
|
|
||||||
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
||||||
return { period_from: iso(from), period_to: iso(to) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Runs() {
|
export function Runs() {
|
||||||
const { client } = useZino();
|
const { client } = useZino();
|
||||||
const scope = useScope();
|
const scope = useScope();
|
||||||
@ -52,7 +43,7 @@ export function Runs() {
|
|||||||
cols={[{ key: 'dealer_name', label: 'Dealer' }, { key: 'scheme_name', label: 'Scheme', render: (r) => `${String(r.scheme_name)} · ${String(r.component_name)}` }, { key: 'measured_qty', label: 'MT', align: 'right', render: (r) => mt(r.measured_qty, 0) }, { key: 'slab_label', label: 'Slab', render: (r) => (r.gate_passed ? String(r.slab_label ?? '') : <span className="text-warn">{String(r.gate_reason ?? '')}</span>) }, { key: 'incentive_amount', label: 'Amount', align: 'right', render: (r) => inr(r.incentive_amount) }, { key: 'status', label: 'Status', render: (r) => <Badge tone={r.status === 'FINAL' ? 'emerald' : 'amber'}>{String(r.status)}</Badge> }]} />
|
cols={[{ key: 'dealer_name', label: 'Dealer' }, { key: 'scheme_name', label: 'Scheme', render: (r) => `${String(r.scheme_name)} · ${String(r.component_name)}` }, { key: 'measured_qty', label: 'MT', align: 'right', render: (r) => mt(r.measured_qty, 0) }, { key: 'slab_label', label: 'Slab', render: (r) => (r.gate_passed ? String(r.slab_label ?? '') : <span className="text-warn">{String(r.gate_reason ?? '')}</span>) }, { key: 'incentive_amount', label: 'Amount', align: 'right', render: (r) => inr(r.incentive_amount) }, { key: 'status', label: 'Status', render: (r) => <Badge tone={r.status === 'FINAL' ? 'emerald' : 'amber'}>{String(r.status)}</Badge> }]} />
|
||||||
</Modal>
|
</Modal>
|
||||||
<ActivityForm open={req} onClose={() => setReq(false)} workflow={WF.RUN} activityUid={ACT.RUN_REQUEST} title="Request a settlement run" subtitle="Pick the period; the engine computes it provisionally and the Analyst reports."
|
<ActivityForm open={req} onClose={() => setReq(false)} workflow={WF.RUN} activityUid={ACT.RUN_REQUEST} title="Request a settlement run" subtitle="Pick the period; the engine computes it provisionally and the Analyst reports."
|
||||||
prefill={{ frequency: 'MONTHLY', ...lastMonth() }} onDone={() => runs.refetch()} />
|
prefill={{ frequency: 'MONTHLY', period_from: '2026-09-01', period_to: '2026-09-30' }} onDone={() => runs.refetch()} />
|
||||||
{decide && <ActivityForm open onClose={() => setDecide(null)} workflow={WF.RUN} activityUid={decide.act} instanceId={decide.id} title={decide.title} danger={decide.danger} onDone={() => runs.refetch()} />}
|
{decide && <ActivityForm open onClose={() => setDecide(null)} workflow={WF.RUN} activityUid={decide.act} instanceId={decide.id} title={decide.title} danger={decide.danger} onDone={() => runs.refetch()} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -113,11 +113,3 @@ body { margin: 0; background: var(--color-bg); color: var(--color-ink); font-fam
|
|||||||
/* progress bar toward the next slab */
|
/* progress bar toward the next slab */
|
||||||
.bar { height: 6px; border-radius: 999px; background: var(--color-panel3); overflow: hidden; }
|
.bar { height: 6px; border-radius: 999px; background: var(--color-panel3); overflow: hidden; }
|
||||||
.bar > i { display: block; height: 100%; background: var(--color-brand); border-radius: 999px; }
|
.bar > i { display: block; height: 100%; background: var(--color-brand); border-radius: 999px; }
|
||||||
|
|
||||||
/* the dealer app's bottom tab bar clears the phone's home indicator */
|
|
||||||
.tabbar { padding-bottom: env(safe-area-inset-bottom, 0px); }
|
|
||||||
@keyframes meru-sheet { from { transform: translateY(24px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
|
||||||
.sheet { animation: meru-sheet 180ms ease-out; }
|
|
||||||
@media (prefers-reduced-motion: reduce) { .sheet { animation: none; } }
|
|
||||||
/* a big number the thumb can't miss */
|
|
||||||
.hero-num { font-size: 2rem; line-height: 1.05; letter-spacing: -0.02em; }
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user