249 lines
12 KiB
JavaScript
249 lines
12 KiB
JavaScript
import { useEffect, useState, useCallback } from 'react';
|
|
import {
|
|
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, XAxis, YAxis,
|
|
Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
|
} from 'recharts';
|
|
import { Flame, Trophy, TrendingUp, Award, BarChart3 as BarChartIcon, UploadCloud } from 'lucide-react';
|
|
import { api } from '../api/client';
|
|
import { Spinner, EmptyState } from '../components/Atoms';
|
|
import { NavigationHeader } from '../components/NavigationHeader';
|
|
import { UploadExcelModal } from '../components/UploadExcelModal';
|
|
|
|
const PALETTE = ['#e2a33d', '#3f8556', '#8b2e3c', '#2c3e63', '#c2872a'];
|
|
|
|
export function AnalyticsPage() {
|
|
const [comparison, setComparison] = useState(null);
|
|
const [comparisonLoading, setComparisonLoading] = useState(true);
|
|
|
|
const [trending, setTrending] = useState([]);
|
|
const [trendingLoading, setTrendingLoading] = useState(true);
|
|
const [window_, setWindow] = useState('weekly');
|
|
|
|
const [topProducts, setTopProducts] = useState([]);
|
|
const [topBy, setTopBy] = useState('revenue');
|
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
|
|
|
const reloadData = useCallback(() => {
|
|
setComparisonLoading(true);
|
|
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
|
setTrendingLoading(true);
|
|
api.getTrending({ window: window_, scope: 'overall', top_k: 10 }).then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
|
|
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
|
|
}, [window_, topBy]);
|
|
|
|
useEffect(() => {
|
|
api.getStoreComparison().then(setComparison).catch(() => setComparison(null)).finally(() => setComparisonLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setTrendingLoading(true);
|
|
api.getTrending({ window: window_, scope: 'overall', top_k: 10 })
|
|
.then(setTrending).catch(() => setTrending([])).finally(() => setTrendingLoading(false));
|
|
}, [window_]);
|
|
|
|
useEffect(() => {
|
|
api.getTopProducts({ by: topBy, order: 'top', limit: 8 }).then(setTopProducts).catch(() => setTopProducts([]));
|
|
}, [topBy]);
|
|
|
|
const stores = comparison?.stores || [];
|
|
|
|
return (
|
|
<div className="flex h-screen w-full flex-col overflow-y-auto bg-paper-100">
|
|
<NavigationHeader title="Chain Analytics" subtitle="Store comparison, trending products & top sellers" icon={BarChartIcon}>
|
|
<div className="flex items-center justify-end px-6 pb-1 -mt-2">
|
|
<button
|
|
onClick={() => setIsUploadOpen(true)}
|
|
className="flex items-center gap-1.5 rounded-full bg-amber-600 px-3.5 py-1.5 text-xs font-semibold text-white transition hover:bg-amber-700 shadow-xs active:scale-95"
|
|
>
|
|
<UploadCloud className="h-3.5 w-3.5" />
|
|
<span>Upload Sales Analytics (Excel / CSV)</span>
|
|
</button>
|
|
</div>
|
|
</NavigationHeader>
|
|
|
|
<div className="mx-auto w-full max-w-6xl flex-1 px-6 py-6">
|
|
{comparisonLoading ? (
|
|
<Spinner label="Loading chain analytics…" />
|
|
) : !comparison || stores.length === 0 ? (
|
|
<EmptyState icon={TrendingUp} title="No analytics yet" subtitle="Seed and train the store intelligence models first (see /stores page)." />
|
|
) : (
|
|
<>
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<ChartCard title="Revenue by store" icon={Trophy}>
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={stores}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
|
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
|
<YAxis tick={{ fontSize: 12 }} />
|
|
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
|
<Bar dataKey="revenue" fill="#e2a33d" radius={[6, 6, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
|
|
<ChartCard title="Profit by store" icon={Award}>
|
|
<ResponsiveContainer width="100%" height={260}>
|
|
<BarChart data={stores}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
|
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
|
<YAxis tick={{ fontSize: 12 }} />
|
|
<Tooltip formatter={(v) => `₹${Number(v).toLocaleString('en-IN')}`} />
|
|
<Bar dataKey="profit" fill="#3f8556" radius={[6, 6, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
|
<ChartCard title="Orders vs. average order value" icon={TrendingUp}>
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<LineChart data={stores}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#e4d8bd" />
|
|
<XAxis dataKey="store_id" tick={{ fontSize: 12 }} />
|
|
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
|
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 12 }} />
|
|
<Tooltip />
|
|
<Legend />
|
|
<Line yAxisId="left" type="monotone" dataKey="order_count" name="Orders" stroke="#2c3e63" strokeWidth={2} />
|
|
<Line yAxisId="right" type="monotone" dataKey="avg_order_value" name="Avg order value (₹)" stroke="#c2872a" strokeWidth={2} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
|
|
<ChartCard title="Simulated footfall share" icon={Flame}>
|
|
<ResponsiveContainer width="100%" height={240}>
|
|
<PieChart>
|
|
<Pie data={stores} dataKey="footfall_simulated" nameKey="store_id" outerRadius={90} label={(e) => e.store_id}>
|
|
{stores.map((_, i) => <Cell key={i} fill={PALETTE[i % PALETTE.length]} />)}
|
|
</Pie>
|
|
<Tooltip />
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</ChartCard>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
|
<Badge label="Best performing" value={comparison.best_performing} />
|
|
<Badge label="Highest revenue" value={comparison.highest_revenue} />
|
|
<Badge label="Highest profit" value={comparison.highest_profit} />
|
|
<Badge label="Lowest performing" value={comparison.lowest_performing} />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
|
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
|
<Flame className="h-4 w-4 text-amber-500" /> Trending products
|
|
</h3>
|
|
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
|
{['today', 'weekly', 'monthly'].map((w) => (
|
|
<button
|
|
key={w}
|
|
onClick={() => setWindow(w)}
|
|
className={`rounded-full px-2.5 py-1 capitalize transition ${window_ === w ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
{w}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{trendingLoading ? (
|
|
<Spinner label="Scoring products…" />
|
|
) : trending.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-slate-400">No trending data yet - train the trending model first.</p>
|
|
) : (
|
|
<ol className="space-y-1.5">
|
|
{trending.map((t) => (
|
|
<li key={`${t.brand}-${t.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-amber-50/50">
|
|
<span className="flex items-center gap-2 truncate">
|
|
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{t.rank}</span>
|
|
<span className="truncate text-ink-900">{prettify(t.image_id)}</span>
|
|
<span className="shrink-0 text-xs text-slate-400">{t.brand}</span>
|
|
</span>
|
|
<span className="shrink-0 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-600">
|
|
{t.trend_score.toFixed(1)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
|
<Trophy className="h-4 w-4 text-leaf-600" /> Top products
|
|
</h3>
|
|
<div className="flex gap-1 rounded-full bg-ink-100 p-0.5 text-xs">
|
|
{['revenue', 'units'].map((b) => (
|
|
<button
|
|
key={b}
|
|
onClick={() => setTopBy(b)}
|
|
className={`rounded-full px-2.5 py-1 capitalize transition ${topBy === b ? 'bg-paper-50 font-medium text-ink-950 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
by {b}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{topProducts.length === 0 ? (
|
|
<p className="py-6 text-center text-sm text-slate-400">No sales data yet.</p>
|
|
) : (
|
|
<ol className="space-y-1.5">
|
|
{topProducts.map((p, i) => (
|
|
<li key={`${p.brand}-${p.image_id}`} className="flex items-center justify-between rounded-lg px-2 py-1.5 text-sm hover:bg-leaf-100/40">
|
|
<span className="flex items-center gap-2 truncate">
|
|
<span className="w-5 shrink-0 text-center font-mono text-xs text-slate-400">#{i + 1}</span>
|
|
<span className="truncate text-ink-900">{prettify(p.image_id)}</span>
|
|
<span className="shrink-0 text-xs text-slate-400">{p.brand}</span>
|
|
</span>
|
|
<span className="shrink-0 font-mono text-xs text-ink-700">
|
|
{topBy === 'revenue' ? `₹${Number(p.revenue).toLocaleString('en-IN')}` : `${p.units_sold} units`}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<UploadExcelModal
|
|
isOpen={isUploadOpen}
|
|
onClose={() => setIsUploadOpen(false)}
|
|
tabType="analytics"
|
|
tabTitle="Sales Analytics Data"
|
|
onSuccess={() => reloadData()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartCard({ title, icon: Icon, children }) {
|
|
return (
|
|
<div className="rounded-xl border border-ink-900/10 bg-white p-4">
|
|
<h3 className="mb-2 flex items-center gap-1.5 font-display text-sm font-semibold text-ink-950">
|
|
<Icon className="h-4 w-4 text-amber-500" /> {title}
|
|
</h3>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Badge({ label, value }) {
|
|
if (!value) return null;
|
|
return (
|
|
<span className="rounded-full border border-ink-900/10 bg-white px-3 py-1">
|
|
<span className="text-slate-400">{label}:</span> <span className="font-medium text-ink-900">{value}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function prettify(imageId) {
|
|
if (!imageId) return '';
|
|
const parts = imageId.split('_').filter((p, i, arr) => !(i > 0 && arr[i - 1] === p));
|
|
return parts.join(' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|