160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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, isError } = 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 (isError) {
|
|
return (
|
|
<Card>
|
|
<CardHeader><CardTitle>Discount Impact</CardTitle></CardHeader>
|
|
<CardContent>
|
|
<div className="h-[300px] flex items-center justify-center">
|
|
<p className="text-sm text-destructive">Failed to load discount data</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|