Redo analytics page

This commit is contained in:
2026-02-07 13:44:51 -05:00
parent 8044771301
commit 9b2f9016f6
18 changed files with 1984 additions and 1743 deletions

View File

@@ -21,20 +21,30 @@ BEGIN
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
-- 1. Calculate Average Lead Time
-- For each completed PO, find the earliest receiving from the same supplier
-- within 180 days, then average those per-PO lead times per product.
RAISE NOTICE 'Calculating Average Lead Time...';
WITH LeadTimes AS (
WITH po_first_receiving AS (
SELECT
po.pid,
-- Calculate lead time by looking at when items ordered on POs were received
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
po.po_id,
po.date::date AS po_date,
MIN(r.received_date::date) AS first_receive_date
FROM public.purchase_orders po
-- Join to receivings table to find actual receipts
JOIN public.receivings r ON r.pid = po.pid
WHERE po.status = 'done' -- Only include completed POs
AND r.received_date >= po.date -- Ensure received date is not before order date
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
GROUP BY po.pid
JOIN public.receivings r
ON r.pid = po.pid
AND r.supplier_id = po.supplier_id -- same supplier
AND r.received_date >= po.date -- received after order
AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
WHERE po.status = 'done'
GROUP BY po.pid, po.po_id, po.date
),
LeadTimes AS (
SELECT
pid,
ROUND(AVG(GREATEST(1, first_receive_date - po_date))) AS avg_days
FROM po_first_receiving
GROUP BY pid
)
UPDATE public.product_metrics pm
SET avg_lead_time_days = lt.avg_days::int

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AgingCohort {
cohort: string;
productCount: number;
avgSellThrough: number;
stockCost: number;
revenue: number;
unitsSold: number;
}
function getSellThroughColor(rate: number): string {
if (rate >= 30) return METRIC_COLORS.revenue;
if (rate >= 15) return METRIC_COLORS.orders;
if (rate >= 5) return METRIC_COLORS.comparison;
return '#ef4444';
}
export function AgingSellThrough() {
const { data, isLoading } = useQuery<AgingCohort[]>({
queryKey: ['aging-sell-through'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/aging`);
if (!response.ok) throw new Error('Failed to fetch aging data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Aging & Sell-Through</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading aging data...</div>
</div>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through Rate by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % for products by age since first received
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Sell-through: <span className="font-medium">{d.avgSellThrough}%</span></p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="avgSellThrough" name="Sell-Through %" radius={[4, 4, 0, 0]}>
{data.map((entry, i) => (
<Cell key={i} fill={getSellThroughColor(entry.avgSellThrough)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Capital Tied Up by Age</CardTitle>
<p className="text-xs text-muted-foreground">
Stock investment distribution across product age cohorts
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="cohort" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={formatCurrency} tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as AgingCohort;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">Age: {d.cohort}</p>
<p>Stock cost: {formatCurrency(d.stockCost)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>{d.unitsSold.toLocaleString()} units sold (30d)</p>
</div>
);
}}
/>
<Bar dataKey="stockCost" name="Stock Investment" fill={METRIC_COLORS.aov} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ScatterChart,
Scatter,
ZAxis,
Cell,
ReferenceLine,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface VendorData {
vendor: string;
productCount: number;
stockCost: number;
profit30d: number;
revenue30d: number;
gmroi: number;
}
interface EfficiencyData {
vendors: VendorData[];
}
function getGmroiColor(gmroi: number): string {
if (gmroi >= 1) return METRIC_COLORS.revenue; // emerald — good
if (gmroi >= 0.3) return METRIC_COLORS.comparison; // amber — ok
return '#ef4444'; // red — poor
}
export function CapitalEfficiency() {
const { data, isLoading } = useQuery<EfficiencyData>({
queryKey: ['capital-efficiency'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/efficiency`);
if (!response.ok) throw new Error('Failed to fetch capital efficiency');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Capital Efficiency</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading efficiency...</div>
</div>
</CardContent>
</Card>
);
}
// Top 15 by GMROI for bar chart
const sortedGmroi = [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15);
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>GMROI by Vendor</CardTitle>
<p className="text-xs text-muted-foreground">
Gross margin return on inventory investment (top vendors by stock value)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={sortedGmroi} layout="vertical">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="vendor"
width={140}
tick={{ fontSize: 11 }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p>
<p>GMROI: <span className="font-medium">{d.gmroi.toFixed(2)}</span></p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<ReferenceLine x={1} stroke="#9ca3af" strokeDasharray="3 3" label={{ value: '1.0', position: 'top', fontSize: 10 }} />
<Bar dataKey="gmroi" name="GMROI" radius={[0, 4, 4, 0]}>
{sortedGmroi.map((entry, i) => (
<Cell key={i} fill={getGmroiColor(entry.gmroi)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Investment vs Profit by Vendor</CardTitle>
<p className="text-xs text-muted-foreground">
Bubble size = product count. Ideal: high profit, low stock cost.
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="stockCost"
name="Stock Investment"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
/>
<YAxis
dataKey="profit30d"
name="Profit (30d)"
tickFormatter={formatCurrency}
tick={{ fontSize: 11 }}
type="number"
/>
<ZAxis dataKey="productCount" range={[40, 400]} name="Products" />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as VendorData;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.vendor}</p>
<p>Stock Investment: {formatCurrency(d.stockCost)}</p>
<p>Profit (30d): {formatCurrency(d.profit30d)}</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
<p>{d.productCount} products</p>
</div>
);
}}
/>
<Scatter data={data.vendors} fill={METRIC_COLORS.orders} fillOpacity={0.6} />
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,202 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, PieChart, Pie, Cell, Legend } from 'recharts';
import config from '../../config';
interface CategoryData {
performance: {
category: string;
categoryPath: string; // Full hierarchy path
revenue: number;
profit: number;
growth: number;
productCount: number;
}[];
distribution: {
category: string;
categoryPath: string; // Full hierarchy path
value: number;
}[];
trends: {
category: string;
categoryPath: string; // Full hierarchy path
month: string;
sales: number;
}[];
}
const COLORS = ['#4ade80', '#60a5fa', '#f87171', '#fbbf24', '#a78bfa', '#f472b6'];
export function CategoryPerformance() {
const { data, isLoading } = useQuery<CategoryData>({
queryKey: ['category-performance'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/categories`);
if (!response.ok) {
throw new Error('Failed to fetch category performance');
}
const rawData = await response.json();
return {
performance: rawData.performance.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
revenue: Number(item.revenue) || 0,
profit: Number(item.profit) || 0,
growth: Number(item.growth) || 0,
productCount: Number(item.productCount) || Number(item.productcount) || 0
})),
distribution: rawData.distribution.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
value: Number(item.value) || 0
})),
trends: rawData.trends.map((item: any) => ({
category: item.category || '',
categoryPath: item.categoryPath || item.categorypath || item.category || '',
month: item.month || '',
sales: Number(item.sales) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading category performance...</div>;
}
const formatGrowth = (growth: number) => {
const value = growth >= 0 ? `+${growth.toFixed(1)}%` : `${growth.toFixed(1)}%`;
const color = growth >= 0 ? 'text-green-500' : 'text-red-500';
return <span className={color}>{value}</span>;
};
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Category Revenue Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.distribution}
dataKey="value"
nameKey="categoryPath"
cx="50%"
cy="50%"
outerRadius={100}
fill="#8884d8"
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
>
{data.distribution.map((entry, index) => (
<Cell
key={`${entry.category}-${entry.value}-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, _: string, props: any) => [
`$${value.toLocaleString()}`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Revenue</div>
</div>
]}
/>
<Legend
formatter={(value) => getShortCategoryName(value)}
wrapperStyle={{ fontSize: '12px' }}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Category Growth Rates</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.performance}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Growth Rate</div>
</div>
]}
/>
<Bar
dataKey="growth"
fill="#4ade80"
name="Growth Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Category Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.performance.map((category) => (
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
<div className="flex-1">
<div className="space-y-1">
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
{category.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${category.revenue.toLocaleString()} revenue
</p>
<p className="text-sm text-muted-foreground">
${category.profit.toLocaleString()} profit
</p>
<p className="text-sm text-muted-foreground">
Growth: {formatGrowth(category.growth)}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,146 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface DiscountRow {
abcClass: string;
discountBucket: string;
productCount: number;
avgSellThrough: number;
revenue: number;
discountAmount: number;
profit: number;
}
const CLASS_COLORS: Record<string, string> = {
A: METRIC_COLORS.revenue,
B: METRIC_COLORS.orders,
C: METRIC_COLORS.comparison,
};
export function DiscountImpact() {
const { data, isLoading } = useQuery<DiscountRow[]>({
queryKey: ['discount-impact'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/discounts`);
if (!response.ok) throw new Error('Failed to fetch discount data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading discount data...</div>
</div>
</CardContent>
</Card>
);
}
// Pivot: for each discount bucket, show avg sell-through by ABC class
const buckets = ['No Discount', '1-10%', '11-20%', '21-30%', '30%+'];
const chartData = buckets.map(bucket => {
const row: Record<string, string | number> = { bucket };
['A', 'B', 'C'].forEach(cls => {
const match = data.find(d => d.discountBucket === bucket && d.abcClass === cls);
row[`Class ${cls}`] = match?.avgSellThrough || 0;
});
return row;
});
// Summary by ABC class
const classSummary = ['A', 'B', 'C'].map(cls => {
const rows = data.filter(d => d.abcClass === cls);
return {
abcClass: cls,
totalProducts: rows.reduce((s, r) => s + r.productCount, 0),
totalDiscounts: rows.reduce((s, r) => s + r.discountAmount, 0),
totalRevenue: rows.reduce((s, r) => s + r.revenue, 0),
totalProfit: rows.reduce((s, r) => s + r.profit, 0),
};
});
return (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sell-Through by Discount Level</CardTitle>
<p className="text-xs text-muted-foreground">
Avg 30-day sell-through % at each discount bracket, by ABC class
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="bucket" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="Class A" fill={CLASS_COLORS.A} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class B" fill={CLASS_COLORS.B} radius={[2, 2, 0, 0]} />
<Bar dataKey="Class C" fill={CLASS_COLORS.C} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Discount Leakage by Class</CardTitle>
<p className="text-xs text-muted-foreground">
How much discount is given relative to revenue per ABC class
</p>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue</th>
<th className="px-4 py-2 text-right font-medium">Discounts</th>
<th className="px-4 py-2 text-right font-medium">Disc %</th>
<th className="px-4 py-2 text-right font-medium">Profit</th>
</tr>
</thead>
<tbody>
{classSummary.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">Class {row.abcClass}</td>
<td className="px-4 py-2 text-right">{row.totalProducts.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalRevenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalDiscounts)}</td>
<td className="px-4 py-2 text-right">
{row.totalRevenue > 0
? ((row.totalDiscounts / (row.totalRevenue + row.totalDiscounts)) * 100).toFixed(1)
: '0'}%
</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.totalProfit)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { TrendingUp, TrendingDown } from 'lucide-react';
interface GrowthRow {
abcClass: string;
growthBucket: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface GrowthSummary {
totalWithYoy: number;
growingCount: number;
decliningCount: number;
avgGrowth: number;
medianGrowth: number;
}
interface GrowthData {
byClass: GrowthRow[];
summary: GrowthSummary;
}
const GROWTH_COLORS: Record<string, string> = {
'Strong Growth (>50%)': METRIC_COLORS.revenue,
'Growing (0-50%)': '#34d399',
'Declining (0-50%)': METRIC_COLORS.comparison,
'Sharp Decline (>50%)': '#ef4444',
};
export function GrowthMomentum() {
const { data, isLoading } = useQuery<GrowthData>({
queryKey: ['growth-momentum'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/growth`);
if (!response.ok) throw new Error('Failed to fetch growth data');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>YoY Growth Momentum</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading growth data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary } = data;
const growthPct = summary.totalWithYoy > 0
? ((summary.growingCount / summary.totalWithYoy) * 100).toFixed(0)
: '0';
// Pivot: for each ABC class, show product counts by growth bucket
const classes = ['A', 'B', 'C'];
const buckets = ['Strong Growth (>50%)', 'Growing (0-50%)', 'Declining (0-50%)', 'Sharp Decline (>50%)'];
const chartData = classes.map(cls => {
const row: Record<string, string | number> = { abcClass: `Class ${cls}` };
buckets.forEach(bucket => {
const match = data.byClass.find(d => d.abcClass === cls && d.growthBucket === bucket);
row[bucket] = match?.productCount || 0;
});
return row;
});
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Growing</p>
<p className="text-xl font-bold">{growthPct}%</p>
<p className="text-xs text-muted-foreground">{summary.growingCount.toLocaleString()} products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<TrendingDown className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Declining</p>
<p className="text-xl font-bold">{summary.decliningCount.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">products</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Avg YoY Growth</p>
<p className={`text-2xl font-bold ${summary.avgGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.avgGrowth > 0 ? '+' : ''}{summary.avgGrowth}%
</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Median YoY Growth</p>
<p className={`text-2xl font-bold ${summary.medianGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{summary.medianGrowth > 0 ? '+' : ''}{summary.medianGrowth}%
</p>
<p className="text-xs text-muted-foreground">{summary.totalWithYoy.toLocaleString()} products tracked</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Growth Distribution by ABC Class</CardTitle>
<p className="text-xs text-muted-foreground">
Year-over-year sales growth segmented by product importance
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 11 }} label={{ value: 'Products', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{buckets.map(bucket => (
<Bar
key={bucket}
dataKey={bucket}
stackId="growth"
fill={GROWTH_COLORS[bucket]}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Line,
ComposedChart,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
interface TrendPoint {
date: string;
stockoutCount: number;
unitsSold: number;
}
type Period = 30 | 90 | 365;
function formatDate(dateStr: string, period: Period): string {
const d = new Date(dateStr);
if (period === 365) return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
export function InventoryTrends() {
const [period, setPeriod] = useState<Period>(90);
const { data, isLoading } = useQuery<TrendPoint[]>({
queryKey: ['inventory-trends', period],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/inventory-trends?period=${period}`);
if (!response.ok) throw new Error('Failed to fetch inventory trends');
return response.json();
},
});
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Daily Sales Activity & Stockouts</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
Units sold per day with stockout product count overlay
</p>
</div>
<div className="flex gap-1">
{([30, 90, 365] as Period[]).map((p) => (
<button
key={p}
onClick={() => setPeriod(p)}
className={`px-3 py-1 text-xs rounded-md transition-colors ${
period === p
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'
}`}
>
{p === 365 ? '1Y' : `${p}D`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isLoading || !data ? (
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading trends...</div>
</div>
) : (
<ResponsiveContainer width="100%" height={350}>
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tickFormatter={(v) => formatDate(v, period)}
tick={{ fontSize: 12 }}
interval={period === 365 ? 29 : period === 90 ? 6 : 2}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Units Sold', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
width={60}
label={{ value: 'Stockouts', angle: 90, position: 'insideRight', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<Tooltip
labelFormatter={(v) => new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })}
formatter={(value: number, name: string) => [value.toLocaleString(), name]}
/>
<Bar
yAxisId="left"
dataKey="unitsSold"
fill={METRIC_COLORS.orders}
name="Units Sold"
radius={[2, 2, 0, 0]}
opacity={0.7}
/>
<Line
yAxisId="right"
type="monotone"
dataKey="stockoutCount"
stroke="#ef4444"
name="Products Stocked Out"
strokeWidth={1.5}
dot={false}
strokeDasharray="4 2"
/>
</ComposedChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,213 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
CartesianGrid,
} from 'recharts';
import config from '../../config';
import { PackageX, Archive } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface AbcItem {
abcClass: string;
productCount: number;
revenue: number;
stockCost: number;
profit: number;
unitsSold: number;
}
interface StockIssues {
deadStockCount: number;
deadStockCost: number;
deadStockRetail: number;
overstockCount: number;
overstockCost: number;
overstockRetail: number;
}
interface PortfolioData {
abcBreakdown: AbcItem[];
stockIssues: StockIssues;
}
export function PortfolioAnalysis() {
const { data, isLoading } = useQuery<PortfolioData>({
queryKey: ['portfolio-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/portfolio`);
if (!response.ok) throw new Error('Failed to fetch portfolio analysis');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Portfolio & ABC Analysis</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading portfolio...</div>
</div>
</CardContent>
</Card>
);
}
// Include all classes — rename N/A to "Unclassified"
const allClasses = data.abcBreakdown.map(r => ({
...r,
abcClass: r.abcClass === 'N/A' ? 'Unclassified' : r.abcClass,
}));
const totalRevenue = allClasses.reduce((s, r) => s + r.revenue, 0);
const totalStockCost = allClasses.reduce((s, r) => s + r.stockCost, 0);
const totalProducts = allClasses.reduce((s, r) => s + r.productCount, 0);
// Compute percentage data for the grouped bar chart
const chartData = allClasses.map(r => ({
abcClass: r.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${r.abcClass}`,
'% of Products': totalProducts > 0 ? Number(((r.productCount / totalProducts) * 100).toFixed(1)) : 0,
'% of Revenue': totalRevenue > 0 ? Number(((r.revenue / totalRevenue) * 100).toFixed(1)) : 0,
'% of Stock Investment': totalStockCost > 0 ? Number(((r.stockCost / totalStockCost) * 100).toFixed(1)) : 0,
}));
const abcOnly = allClasses.filter(r => ['A', 'B', 'C'].includes(r.abcClass));
const abcRevenue = abcOnly.reduce((s, r) => s + r.revenue, 0);
const aClass = allClasses.find(r => r.abcClass === 'A');
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>ABC Class Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="abcClass" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={(v) => `${v}%`} tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => [`${value}%`]} />
<Legend />
<Bar dataKey="% of Products" fill={METRIC_COLORS.orders} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Revenue" fill={METRIC_COLORS.revenue} radius={[2, 2, 0, 0]} />
<Bar dataKey="% of Stock Investment" fill={METRIC_COLORS.aov} radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<div className="grid gap-4 grid-rows-3">
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<TrendingUpIcon className="h-5 w-5 text-green-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">A-Class Products</p>
<p className="text-xs text-muted-foreground">
{aClass ? aClass.productCount.toLocaleString() : 0} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold">
{abcRevenue > 0 && aClass ? ((aClass.revenue / abcRevenue) * 100).toFixed(0) : 0}% of classified revenue
</p>
<p className="text-xs text-muted-foreground">
{formatCurrency(aClass?.revenue || 0)} (30d)
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<Archive className="h-5 w-5 text-amber-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Dead Stock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.deadStockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-amber-500">
{formatCurrency(data.stockIssues.deadStockCost)}
</p>
<p className="text-xs text-muted-foreground">capital tied up</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<PackageX className="h-5 w-5 text-red-500" />
</div>
<div className="flex-1">
<p className="text-sm font-medium">Overstock</p>
<p className="text-xs text-muted-foreground">
{data.stockIssues.overstockCount.toLocaleString()} products
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-500">
{formatCurrency(data.stockIssues.overstockCost)}
</p>
<p className="text-xs text-muted-foreground">excess investment</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* ABC breakdown table */}
<Card>
<CardContent className="pt-6">
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-2 text-left font-medium">Class</th>
<th className="px-4 py-2 text-right font-medium">Products</th>
<th className="px-4 py-2 text-right font-medium">Revenue (30d)</th>
<th className="px-4 py-2 text-right font-medium">Profit (30d)</th>
<th className="px-4 py-2 text-right font-medium">Stock Cost</th>
<th className="px-4 py-2 text-right font-medium">Units Sold (30d)</th>
</tr>
</thead>
<tbody>
{allClasses.map((row) => (
<tr key={row.abcClass} className="border-b">
<td className="px-4 py-2 font-medium">{row.abcClass === 'Unclassified' ? 'Unclassified' : `Class ${row.abcClass}`}</td>
<td className="px-4 py-2 text-right">{row.productCount.toLocaleString()}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.profit)}</td>
<td className="px-4 py-2 text-right">{formatCurrency(row.stockCost)}</td>
<td className="px-4 py-2 text-right">{row.unitsSold.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
);
}
function TrendingUpIcon({ className }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" />
<polyline points="16 7 22 7 22 13" />
</svg>
);
}

View File

@@ -1,232 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, ZAxis, LineChart, Line } from 'recharts';
import config from '../../config';
interface PriceData {
pricePoints: {
price: number;
salesVolume: number;
revenue: number;
category: string;
}[];
elasticity: {
date: string;
price: number;
demand: number;
}[];
recommendations: {
product: string;
currentPrice: number;
recommendedPrice: number;
potentialRevenue: number;
confidence: number;
}[];
}
export function PriceAnalysis() {
const { data, isLoading, error } = useQuery<PriceData>({
queryKey: ['price-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.pricePoints) {
return {
pricePoints: [],
elasticity: [],
recommendations: []
};
}
return {
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
price: Number(item.price) || 0,
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
revenue: Number(item.revenue) || 0,
category: item.category || ''
})),
elasticity: (rawData.elasticity || []).map((item: any) => ({
date: item.date || '',
price: Number(item.price) || 0,
demand: Number(item.demand) || 0
})),
recommendations: (rawData.recommendations || []).map((item: any) => ({
product: item.product || '',
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
confidence: Number(item.confidence) || 0
}))
};
} catch (err) {
console.error('Error fetching price data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading price analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load price analysis. The price metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.pricePoints.length === 0 &&
data.elasticity.length === 0 &&
data.recommendations.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Price Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No price data available. This may be because the price metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Price Point Analysis</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="price"
name="Price"
tickFormatter={(value) => `$${value}`}
/>
<YAxis
dataKey="salesVolume"
name="Sales Volume"
/>
<ZAxis
dataKey="revenue"
range={[50, 400]}
name="Revenue"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
if (name === 'Sales Volume') return [value.toLocaleString(), name];
if (name === 'Revenue') return [`$${value.toLocaleString()}`, name];
return [value, name];
}}
/>
<Scatter
data={data.pricePoints}
fill="#a78bfa"
name="Products"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Price Elasticity</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.elasticity}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis yAxisId="left" orientation="left" stroke="#a78bfa" />
<YAxis
yAxisId="right"
orientation="right"
stroke="#4ade80"
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number, name: string) => {
if (name === 'Price') return [`$${value}`, name];
return [value.toLocaleString(), name];
}}
/>
<Line
yAxisId="left"
type="monotone"
dataKey="demand"
stroke="#a78bfa"
name="Demand"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="price"
stroke="#4ade80"
name="Price"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Price Optimization Recommendations</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.recommendations.map((item) => (
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{item.product}</p>
<p className="text-sm text-muted-foreground">
Current Price: ${item.currentPrice.toFixed(2)}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
Recommended: ${item.recommendedPrice.toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
Potential Revenue: ${item.potentialRevenue.toLocaleString()}
</p>
<p className="text-sm text-muted-foreground">
Confidence: {item.confidence}%
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,180 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import config from '../../config';
interface ProfitData {
byCategory: {
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
overTime: {
date: string;
profitMargin: number;
revenue: number;
cost: number;
}[];
topProducts: {
product: string;
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number;
revenue: number;
cost: number;
}[];
}
export function ProfitAnalysis() {
const { data, isLoading } = useQuery<ProfitData>({
queryKey: ['profit-analysis'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/profit`);
if (!response.ok) {
throw new Error('Failed to fetch profit analysis');
}
const rawData = await response.json();
return {
byCategory: rawData.byCategory.map((item: any) => ({
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
overTime: rawData.overTime.map((item: any) => ({
date: item.date || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
})),
topProducts: rawData.topProducts.map((item: any) => ({
product: item.product || '',
category: item.category || '',
categoryPath: item.categorypath || item.category || '',
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0
}))
};
},
});
if (isLoading || !data) {
return <div>Loading profit analysis...</div>;
}
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Profit Margins by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.byCategory}>
<XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number, _: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Profit Margin</div>
</div>
]}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Profit Margin Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.overTime}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Line
type="monotone"
dataKey="profitMargin"
stroke="#4ade80"
name="Profit Margin"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Top Performing Products by Profit Margin</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.topProducts.map((product) => (
<div key={`${product.product}-${product.category}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{product.product}</p>
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Category:</p>
<p>{product.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
Revenue: ${product.revenue.toLocaleString()}
</p>
</div>
<div className="ml-4 text-right">
<p className="text-sm font-medium">
{product.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
Cost: ${product.cost.toLocaleString()}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,227 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } from 'recharts';
import { Badge } from '@/components/ui/badge';
import config from '../../config';
interface StockData {
turnoverByCategory: {
category: string;
turnoverRate: number;
averageStock: number;
totalSales: number;
}[];
stockLevels: {
date: string;
inStock: number;
lowStock: number;
outOfStock: number;
}[];
criticalItems: {
product: string;
sku: string;
stockQuantity: number;
reorderPoint: number;
turnoverRate: number;
daysUntilStockout: number;
}[];
}
export function StockAnalysis() {
const { data, isLoading, error } = useQuery<StockData>({
queryKey: ['stock-analysis'],
queryFn: async () => {
try {
const response = await fetch(`${config.apiUrl}/analytics/stock`);
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.turnoverByCategory) {
return {
turnoverByCategory: [],
stockLevels: [],
criticalItems: []
};
}
return {
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
category: item.category || '',
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
averageStock: Number(item.averageStock || item.averagestock) || 0,
totalSales: Number(item.totalSales || item.totalsales) || 0
})),
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
date: item.date || '',
inStock: Number(item.inStock || item.instock) || 0,
lowStock: Number(item.lowStock || item.lowstock) || 0,
outOfStock: Number(item.outOfStock || item.outofstock) || 0
})),
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
product: item.product || '',
sku: item.sku || '',
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
}))
};
} catch (err) {
console.error('Error fetching stock data:', err);
throw err;
}
},
retry: 1
});
if (isLoading) {
return <div>Loading stock analysis...</div>;
}
if (error || !data) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-500">
Unable to load stock analysis. The stock metrics may need to be set up in the database.
</p>
</CardContent>
</Card>
);
}
// Early return if no data to display
if (
data.turnoverByCategory.length === 0 &&
data.stockLevels.length === 0 &&
data.criticalItems.length === 0
) {
return (
<Card className="mb-4">
<CardHeader>
<CardTitle>Stock Analysis</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
No stock data available. This may be because the stock metrics haven't been calculated yet.
</p>
</CardContent>
</Card>
);
}
const getStockStatus = (daysUntilStockout: number) => {
if (daysUntilStockout <= 7) {
return <Badge variant="destructive">Critical</Badge>;
}
if (daysUntilStockout <= 14) {
return <Badge variant="outline">Warning</Badge>;
}
return <Badge variant="secondary">OK</Badge>;
};
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Stock Turnover by Category</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.turnoverByCategory}>
<XAxis dataKey="category" />
<YAxis tickFormatter={(value) => `${value.toFixed(1)}x`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}x`, 'Turnover Rate']}
/>
<Bar
dataKey="turnoverRate"
fill="#fbbf24"
name="Turnover Rate"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Stock Level Trends</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data.stockLevels}>
<XAxis
dataKey="date"
tickFormatter={(value) => new Date(value).toLocaleDateString()}
/>
<YAxis />
<Tooltip
labelFormatter={(label) => new Date(label).toLocaleDateString()}
/>
<Line
type="monotone"
dataKey="inStock"
stroke="#4ade80"
name="In Stock"
/>
<Line
type="monotone"
dataKey="lowStock"
stroke="#fbbf24"
name="Low Stock"
/>
<Line
type="monotone"
dataKey="outOfStock"
stroke="#f87171"
name="Out of Stock"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Critical Stock Items</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{data.criticalItems.map((item) => (
<div key={`${item.sku}-${item.product}`} className="flex items-center">
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{item.product}</p>
{getStockStatus(item.daysUntilStockout)}
</div>
<p className="text-sm text-muted-foreground">
SKU: {item.sku}
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
{item.stockQuantity} in stock
</p>
<p className="text-sm text-muted-foreground">
Reorder at: {item.reorderPoint}
</p>
<p className="text-sm text-muted-foreground">
{item.daysUntilStockout} days until stockout
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
PieChart,
Pie,
Cell,
Legend,
} from 'recharts';
import config from '../../config';
import { AlertTriangle, ShieldCheck, DollarSign } from 'lucide-react';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface CoverBucket {
bucket: string;
productCount: number;
stockCost: number;
}
interface DemandPattern {
pattern: string;
productCount: number;
revenue: number;
stockCost: number;
}
interface ServiceStats {
avgFillRate: number;
avgServiceLevel: number;
totalStockoutIncidents: number;
totalLostSalesIncidents: number;
totalLostUnits: number;
totalLostRevenue: number;
productsWithStockouts: number;
avgStockoutRate: number;
}
interface StockHealthData {
coverDistribution: CoverBucket[];
demandPatterns: DemandPattern[];
serviceStats: ServiceStats;
}
// Color palette for demand pattern donut chart
const DEMAND_COLORS = [
METRIC_COLORS.revenue, // emerald
METRIC_COLORS.orders, // blue
METRIC_COLORS.comparison, // amber
METRIC_COLORS.aov, // violet
METRIC_COLORS.secondary, // cyan
];
function getCoverColor(bucket: string): string {
if (bucket.includes('Stockout')) return '#ef4444'; // red
if (bucket.includes('1-7')) return METRIC_COLORS.expense; // orange — critical low
if (bucket.includes('8-14')) return METRIC_COLORS.comparison; // amber — low
if (bucket.includes('15-30')) return '#eab308'; // yellow — watch
if (bucket.includes('31-60')) return METRIC_COLORS.revenue; // emerald — healthy
if (bucket.includes('61-90')) return METRIC_COLORS.orders; // blue — comfortable
if (bucket.includes('91-180')) return METRIC_COLORS.aov; // violet — high
return METRIC_COLORS.secondary; // cyan — excess
}
export function StockHealth() {
const { data, isLoading } = useQuery<StockHealthData>({
queryKey: ['stock-health'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stock-health`);
if (!response.ok) throw new Error('Failed to fetch stock health');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Demand & Stock Health</CardTitle></CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading stock health...</div>
</div>
</CardContent>
</Card>
);
}
const { serviceStats } = data;
return (
<div className="space-y-4">
{/* Service Level Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-green-500/10">
<ShieldCheck className="h-4 w-4 text-green-500" />
</div>
<div>
<p className="text-sm font-medium">Fill Rate</p>
<p className="text-xl font-bold">{serviceStats.avgFillRate}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-blue-500/10">
<ShieldCheck className="h-4 w-4 text-blue-500" />
</div>
<div>
<p className="text-sm font-medium">Service Level</p>
<p className="text-xl font-bold">{serviceStats.avgServiceLevel}%</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-red-500/10">
<AlertTriangle className="h-4 w-4 text-red-500" />
</div>
<div>
<p className="text-sm font-medium">Stockout Incidents</p>
<p className="text-xl font-bold">{serviceStats.totalStockoutIncidents.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{serviceStats.productsWithStockouts} products affected</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<div className="rounded-full p-2 bg-amber-500/10">
<DollarSign className="h-4 w-4 text-amber-500" />
</div>
<div>
<p className="text-sm font-medium">Est. Lost Revenue</p>
<p className="text-xl font-bold">{formatCurrency(serviceStats.totalLostRevenue)}</p>
<p className="text-xs text-muted-foreground">{Math.round(serviceStats.totalLostUnits).toLocaleString()} units</p>
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
{/* Stock Cover Distribution */}
<Card>
<CardHeader>
<CardTitle>Stock Cover Distribution</CardTitle>
<p className="text-xs text-muted-foreground">
Days of stock cover across active replenishable products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.coverDistribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="bucket"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
height={60}
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as CoverBucket;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1">{d.bucket}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Bar dataKey="productCount" name="Products" radius={[4, 4, 0, 0]}>
{data.coverDistribution.map((entry, i) => (
<Cell key={i} fill={getCoverColor(entry.bucket)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Demand Pattern Distribution */}
<Card>
<CardHeader>
<CardTitle>Demand Patterns</CardTitle>
<p className="text-xs text-muted-foreground">
Distribution of demand variability across selling products
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data.demandPatterns}
dataKey="productCount"
nameKey="pattern"
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
label={({ pattern, productCount }) =>
`${pattern} (${productCount.toLocaleString()})`
}
>
{data.demandPatterns.map((_, i) => (
<Cell key={i} fill={DEMAND_COLORS[i % DEMAND_COLORS.length]} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as DemandPattern;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm">
<p className="font-medium mb-1 capitalize">{d.pattern}</p>
<p>{d.productCount.toLocaleString()} products</p>
<p>Revenue (30d): {formatCurrency(d.revenue)}</p>
<p>Stock value: {formatCurrency(d.stockCost)}</p>
</div>
);
}}
/>
<Legend
formatter={(value) => <span className="capitalize text-xs">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ResponsiveContainer,
ScatterChart,
Scatter,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ZAxis,
Cell,
} from 'recharts';
import config from '../../config';
import { METRIC_COLORS } from '@/lib/dashboard/designTokens';
import { formatCurrency } from '@/utils/formatCurrency';
interface RiskProduct {
title: string;
sku: string;
vendor: string;
leadTimeDays: number;
sellsOutInDays: number;
currentStock: number;
velocityDaily: number;
revenue30d: number;
abcClass: string;
}
interface RiskSummary {
atRiskCount: number;
criticalACount: number;
atRiskRevenue: number;
}
interface StockoutRiskData {
summary: RiskSummary;
products: RiskProduct[];
}
function getRiskColor(product: RiskProduct): string {
const buffer = product.sellsOutInDays - product.leadTimeDays;
if (buffer <= 0) return '#ef4444'; // Already past lead time — critical
if (buffer <= 7) return METRIC_COLORS.comparison; // Within a week — warning
return METRIC_COLORS.revenue; // Healthy buffer
}
export function StockoutRisk() {
const { data, isLoading } = useQuery<StockoutRiskData>({
queryKey: ['stockout-risk'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stockout-risk`);
if (!response.ok) throw new Error('Failed to fetch stockout risk');
return response.json();
},
});
if (isLoading || !data) {
return (
<Card>
<CardHeader><CardTitle>Reorder Risk</CardTitle></CardHeader>
<CardContent>
<div className="h-[350px] flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading risk data...</div>
</div>
</CardContent>
</Card>
);
}
const { summary, products } = data;
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At Risk Products</p>
<p className="text-2xl font-bold text-red-500">{summary.atRiskCount}</p>
<p className="text-xs text-muted-foreground">sells out before lead time</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">Critical A-Class</p>
<p className="text-2xl font-bold text-red-500">{summary.criticalACount}</p>
<p className="text-xs text-muted-foreground">top sellers at risk</p>
</CardContent>
</Card>
<Card>
<CardContent className="py-4">
<p className="text-sm font-medium text-muted-foreground">At-Risk Revenue</p>
<p className="text-2xl font-bold text-amber-500">
{formatCurrency(summary.atRiskRevenue)}
</p>
<p className="text-xs text-muted-foreground">monthly revenue exposed</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Lead Time vs Sell-Out Timeline</CardTitle>
<p className="text-xs text-muted-foreground">
Products below the diagonal line will stock out before replenishment arrives (incl. on-order stock)
</p>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<ScatterChart>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="leadTimeDays"
name="Lead Time"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Lead Time (days)', position: 'insideBottom', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<YAxis
dataKey="sellsOutInDays"
name="Sells Out In"
tick={{ fontSize: 11 }}
type="number"
label={{ value: 'Sells Out In (days)', angle: -90, position: 'insideLeft', offset: -5, style: { fontSize: 11, fill: '#9ca3af' } }}
/>
<ZAxis dataKey="revenue30d" range={[30, 300]} name="Revenue" />
{/* Diagonal risk line (y = x): products below this stock out before replenishment */}
<Scatter
data={(() => {
const max = Math.max(...products.map(d => Math.max(d.leadTimeDays, d.sellsOutInDays)));
return [
{ leadTimeDays: 0, sellsOutInDays: 0, revenue30d: 0 },
{ leadTimeDays: max, sellsOutInDays: max, revenue30d: 0 },
];
})()}
line={{ stroke: '#9ca3af', strokeDasharray: '6 3', strokeWidth: 1.5 }}
shape={() => <circle r={0} />}
legendType="none"
isAnimationActive={false}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0].payload as RiskProduct;
if (!d.title) return null; // skip diagonal line points
const buffer = d.sellsOutInDays - d.leadTimeDays;
return (
<div className="rounded-lg border bg-background p-3 shadow-md text-sm max-w-xs">
<p className="font-medium mb-1 truncate">{d.title}</p>
<p className="text-xs text-muted-foreground mb-1">{d.sku} ({d.abcClass})</p>
<p>Lead time: {d.leadTimeDays}d</p>
<p>Sells out in: {d.sellsOutInDays}d</p>
<p className={buffer <= 0 ? 'text-red-500 font-medium' : ''}>
Buffer: {buffer}d {buffer <= 0 ? '(AT RISK)' : ''}
</p>
<p>Stock: {d.currentStock} units</p>
<p>Velocity: {d.velocityDaily.toFixed(1)}/day</p>
<p>Revenue (30d): {formatCurrency(d.revenue30d)}</p>
</div>
);
}}
/>
<Scatter data={products} fillOpacity={0.7}>
{products.map((entry, i) => (
<Cell key={i} fill={getRiskColor(entry)} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,230 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, ScatterChart, Scatter, ZAxis } from 'recharts';
import config from '../../config';
import { useState, useEffect } from 'react';
interface VendorData {
performance: {
vendor: string;
salesVolume: number;
profitMargin: number;
stockTurnover: number;
productCount: number;
growth: number;
}[];
comparison?: {
vendor: string;
salesPerProduct: number;
averageMargin: number;
size: number;
}[];
trends?: {
vendor: string;
month: string;
sales: number;
}[];
}
export function VendorPerformance() {
const [vendorData, setVendorData] = useState<VendorData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Use plain fetch to bypass cache issues with React Query
const fetchData = async () => {
try {
setIsLoading(true);
// Add cache-busting parameter
const response = await fetch(`${config.apiUrl}/analytics/vendors?nocache=${Date.now()}`, {
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
});
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`);
}
const rawData = await response.json();
if (!rawData || !rawData.performance) {
throw new Error('Invalid response format');
}
// Create a complete structure even if some parts are missing
const data: VendorData = {
performance: rawData.performance.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
productCount: Number(vendor.productCount) || 0,
growth: vendor.growth !== null ? Number(vendor.growth) : 0
})),
comparison: rawData.comparison?.map((vendor: any) => ({
vendor: vendor.vendor || '',
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
size: Number(vendor.size) || 0
})) || [],
trends: rawData.trends?.map((vendor: any) => ({
vendor: vendor.vendor || '',
month: vendor.month || '',
sales: Number(vendor.sales) || 0
})) || []
};
setVendorData(data);
} catch (err) {
console.error('Error fetching vendor data:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
if (isLoading) {
return <div>Loading vendor performance...</div>;
}
if (error || !vendorData) {
return <div className="text-red-500">Error loading vendor data: {error}</div>;
}
// Ensure we have at least the performance data
const sortedPerformance = vendorData.performance
.sort((a, b) => b.salesVolume - a.salesVolume)
.slice(0, 10);
// Use simplified version if comparison data is missing
const hasComparisonData = vendorData.comparison && vendorData.comparison.length > 0;
return (
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Top Vendors by Sales Volume</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`} />
<Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Sales Volume']}
/>
<Bar
dataKey="salesVolume"
fill="#60a5fa"
name="Sales Volume"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{hasComparisonData ? (
<Card>
<CardHeader>
<CardTitle>Vendor Performance Matrix</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<ScatterChart>
<XAxis
dataKey="salesPerProduct"
name="Sales per Product"
tickFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
/>
<YAxis
dataKey="averageMargin"
name="Average Margin"
tickFormatter={(value) => `${value.toFixed(0)}%`}
/>
<ZAxis
dataKey="size"
range={[50, 400]}
name="Product Count"
/>
<Tooltip
formatter={(value: number, name: string) => {
if (name === 'Sales per Product') return [`$${value.toLocaleString()}`, name];
if (name === 'Average Margin') return [`${value.toFixed(1)}%`, name];
return [value, name];
}}
/>
<Scatter
data={vendorData.comparison}
fill="#60a5fa"
name="Vendors"
/>
</ScatterChart>
</ResponsiveContainer>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Vendor Profit Margins</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={sortedPerformance}>
<XAxis dataKey="vendor" />
<YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
/>
<Bar
dataKey="profitMargin"
fill="#4ade80"
name="Profit Margin"
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle>Vendor Performance Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{sortedPerformance.map((vendor) => (
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium">{vendor.vendor}</p>
<p className="text-sm text-muted-foreground">
{vendor.productCount} products
</p>
</div>
<div className="ml-4 text-right space-y-1">
<p className="text-sm font-medium">
${vendor.salesVolume.toLocaleString()} sales
</p>
<p className="text-sm text-muted-foreground">
{vendor.profitMargin.toFixed(1)}% margin
</p>
<p className="text-sm text-muted-foreground">
{vendor.stockTurnover.toFixed(1)}x turnover
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,113 +1,142 @@
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis';
import { VendorPerformance } from '../components/analytics/VendorPerformance';
import { StockAnalysis } from '../components/analytics/StockAnalysis';
import { PriceAnalysis } from '../components/analytics/PriceAnalysis';
import { CategoryPerformance } from '../components/analytics/CategoryPerformance';
import { InventoryTrends } from '../components/analytics/InventoryTrends';
import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis';
import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency';
import { StockHealth } from '../components/analytics/StockHealth';
import { AgingSellThrough } from '../components/analytics/AgingSellThrough';
import { StockoutRisk } from '../components/analytics/StockoutRisk';
import { DiscountImpact } from '../components/analytics/DiscountImpact';
import { GrowthMomentum } from '../components/analytics/GrowthMomentum';
import config from '../config';
import { motion } from 'motion/react';
import { DollarSign, RefreshCw, TrendingUp, Calendar } from 'lucide-react';
import { formatCurrency } from '../utils/formatCurrency';
interface AnalyticsStats {
profitMargin: number;
averageMarkup: number;
stockTurnoverRate: number;
vendorCount: number;
categoryCount: number;
averageOrderValue: number;
interface InventorySummary {
stockInvestment: number;
onOrderValue: number;
inventoryTurns: number;
gmroi: number;
avgStockCoverDays: number;
productsInStock: number;
deadStockProducts: number;
deadStockValue: number;
}
export function Analytics() {
const { data: stats, isLoading: statsLoading } = useQuery<AnalyticsStats>({
queryKey: ['analytics-stats'],
const { data: summary, isLoading } = useQuery<InventorySummary>({
queryKey: ['inventory-summary'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/analytics/stats`);
if (!response.ok) {
throw new Error('Failed to fetch analytics stats');
}
const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`);
if (!response.ok) throw new Error('Failed to fetch inventory summary');
return response.json();
},
});
if (statsLoading || !stats) {
return <div className="p-8">Loading analytics...</div>;
}
return (
<motion.div layout className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<motion.div layout className="flex-1 space-y-6 p-8 pt-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* KPI Summary Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Overall Profit Margin
</CardTitle>
<CardTitle className="text-sm font-medium">Stock Investment</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.profitMargin.toFixed(1)}%
</div>
{isLoading || !summary ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<div className="text-2xl font-bold">{formatCurrency(summary.stockInvestment)}</div>
<p className="text-xs text-muted-foreground">
{formatCurrency(summary.onOrderValue)} on order
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Average Markup
</CardTitle>
<CardTitle className="text-sm font-medium">Inventory Turns</CardTitle>
<RefreshCw className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.averageMarkup.toFixed(1)}%
</div>
{isLoading || !summary ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<div className="text-2xl font-bold">{summary.inventoryTurns.toFixed(1)}x</div>
<p className="text-xs text-muted-foreground">annualized (30d basis)</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Stock Turnover Rate
</CardTitle>
<CardTitle className="text-sm font-medium">GMROI</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.stockTurnoverRate.toFixed(2)}x
</div>
{isLoading || !summary ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<div className="text-2xl font-bold">{summary.gmroi.toFixed(2)}</div>
<p className="text-xs text-muted-foreground">
profit per $ invested (30d)
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Stock Cover</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading || !summary ? (
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
) : (
<>
<div className="text-2xl font-bold">{Math.round(summary.avgStockCoverDays)} days</div>
<p className="text-xs text-muted-foreground">
{summary.productsInStock.toLocaleString()} products in stock
</p>
</>
)}
</CardContent>
</Card>
</div>
<Tabs defaultValue="profit" className="space-y-4">
<TabsList className="grid w-full grid-cols-5 lg:w-[600px]">
<TabsTrigger value="profit">Profit</TabsTrigger>
<TabsTrigger value="vendors">Vendors</TabsTrigger>
<TabsTrigger value="stock">Stock</TabsTrigger>
<TabsTrigger value="pricing">Pricing</TabsTrigger>
<TabsTrigger value="categories">Categories</TabsTrigger>
</TabsList>
{/* Section 2: Inventory Value Trends */}
<InventoryTrends />
<TabsContent value="profit" className="space-y-4">
<ProfitAnalysis />
</TabsContent>
{/* Section 3: ABC Portfolio Analysis */}
<PortfolioAnalysis />
<TabsContent value="vendors" className="space-y-4">
<VendorPerformance />
</TabsContent>
{/* Section 4: Capital Efficiency */}
<CapitalEfficiency />
<TabsContent value="stock" className="space-y-4">
<StockAnalysis />
</TabsContent>
{/* Section 5: Demand & Stock Health */}
<StockHealth />
<TabsContent value="pricing" className="space-y-4">
<PriceAnalysis />
</TabsContent>
{/* Section 6: Aging & Sell-Through */}
<AgingSellThrough />
<TabsContent value="categories" className="space-y-4">
<CategoryPerformance />
</TabsContent>
</Tabs>
{/* Section 7: Reorder Risk */}
<StockoutRisk />
{/* Section 8: Discount Impact */}
<DiscountImpact />
{/* Section 9: YoY Growth Momentum */}
<GrowthMomentum />
</motion.div>
);
}

View File

@@ -0,0 +1,5 @@
export function formatCurrency(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}k`;
return `$${value.toFixed(0)}`;
}

File diff suppressed because one or more lines are too long