Redo analytics page
This commit is contained in:
@@ -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
130
inventory/src/components/analytics/AgingSellThrough.tsx
Normal file
130
inventory/src/components/analytics/AgingSellThrough.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal file
161
inventory/src/components/analytics/CapitalEfficiency.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
146
inventory/src/components/analytics/DiscountImpact.tsx
Normal file
146
inventory/src/components/analytics/DiscountImpact.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
inventory/src/components/analytics/GrowthMomentum.tsx
Normal file
160
inventory/src/components/analytics/GrowthMomentum.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
inventory/src/components/analytics/InventoryTrends.tsx
Normal file
127
inventory/src/components/analytics/InventoryTrends.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal file
213
inventory/src/components/analytics/PortfolioAnalysis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
244
inventory/src/components/analytics/StockHealth.tsx
Normal file
244
inventory/src/components/analytics/StockHealth.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
inventory/src/components/analytics/StockoutRisk.tsx
Normal file
174
inventory/src/components/analytics/StockoutRisk.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<TabsContent value="profit" className="space-y-4">
|
||||
<ProfitAnalysis />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vendors" className="space-y-4">
|
||||
<VendorPerformance />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stock" className="space-y-4">
|
||||
<StockAnalysis />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pricing" className="space-y-4">
|
||||
<PriceAnalysis />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="categories" className="space-y-4">
|
||||
<CategoryPerformance />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Section 2: Inventory Value Trends */}
|
||||
<InventoryTrends />
|
||||
|
||||
{/* Section 3: ABC Portfolio Analysis */}
|
||||
<PortfolioAnalysis />
|
||||
|
||||
{/* Section 4: Capital Efficiency */}
|
||||
<CapitalEfficiency />
|
||||
|
||||
{/* Section 5: Demand & Stock Health */}
|
||||
<StockHealth />
|
||||
|
||||
{/* Section 6: Aging & Sell-Through */}
|
||||
<AgingSellThrough />
|
||||
|
||||
{/* Section 7: Reorder Risk */}
|
||||
<StockoutRisk />
|
||||
|
||||
{/* Section 8: Discount Impact */}
|
||||
<DiscountImpact />
|
||||
|
||||
{/* Section 9: YoY Growth Momentum */}
|
||||
<GrowthMomentum />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
5
inventory/src/utils/formatCurrency.ts
Normal file
5
inventory/src/utils/formatCurrency.ts
Normal 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
Reference in New Issue
Block a user