Files
catalogue_frontend/src/pages/AnalyticsPage.jsx
2026-08-12 16:33:39 +05:30

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());
}