Redo analytics page
This commit is contained in:
@@ -21,20 +21,30 @@ BEGIN
|
|||||||
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
RAISE NOTICE 'Running % module. Start Time: %', _module_name, _start_time;
|
||||||
|
|
||||||
-- 1. Calculate Average Lead 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...';
|
RAISE NOTICE 'Calculating Average Lead Time...';
|
||||||
WITH LeadTimes AS (
|
WITH po_first_receiving AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.pid,
|
po.pid,
|
||||||
-- Calculate lead time by looking at when items ordered on POs were received
|
po.po_id,
|
||||||
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
|
po.date::date AS po_date,
|
||||||
|
MIN(r.received_date::date) AS first_receive_date
|
||||||
FROM public.purchase_orders po
|
FROM public.purchase_orders po
|
||||||
-- Join to receivings table to find actual receipts
|
JOIN public.receivings r
|
||||||
JOIN public.receivings r ON r.pid = po.pid
|
ON r.pid = po.pid
|
||||||
WHERE po.status = 'done' -- Only include completed POs
|
AND r.supplier_id = po.supplier_id -- same supplier
|
||||||
AND r.received_date >= po.date -- Ensure received date is not before order date
|
AND r.received_date >= po.date -- received after order
|
||||||
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
|
AND r.received_date <= po.date + INTERVAL '180 days' -- within reasonable window
|
||||||
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
|
WHERE po.status = 'done'
|
||||||
GROUP BY po.pid
|
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
|
UPDATE public.product_metrics pm
|
||||||
SET avg_lead_time_days = lt.avg_days::int
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs';
|
import { InventoryTrends } from '../components/analytics/InventoryTrends';
|
||||||
import { ProfitAnalysis } from '../components/analytics/ProfitAnalysis';
|
import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis';
|
||||||
import { VendorPerformance } from '../components/analytics/VendorPerformance';
|
import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency';
|
||||||
import { StockAnalysis } from '../components/analytics/StockAnalysis';
|
import { StockHealth } from '../components/analytics/StockHealth';
|
||||||
import { PriceAnalysis } from '../components/analytics/PriceAnalysis';
|
import { AgingSellThrough } from '../components/analytics/AgingSellThrough';
|
||||||
import { CategoryPerformance } from '../components/analytics/CategoryPerformance';
|
import { StockoutRisk } from '../components/analytics/StockoutRisk';
|
||||||
|
import { DiscountImpact } from '../components/analytics/DiscountImpact';
|
||||||
|
import { GrowthMomentum } from '../components/analytics/GrowthMomentum';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
|
import { DollarSign, RefreshCw, TrendingUp, Calendar } from 'lucide-react';
|
||||||
|
import { formatCurrency } from '../utils/formatCurrency';
|
||||||
|
|
||||||
interface AnalyticsStats {
|
interface InventorySummary {
|
||||||
profitMargin: number;
|
stockInvestment: number;
|
||||||
averageMarkup: number;
|
onOrderValue: number;
|
||||||
stockTurnoverRate: number;
|
inventoryTurns: number;
|
||||||
vendorCount: number;
|
gmroi: number;
|
||||||
categoryCount: number;
|
avgStockCoverDays: number;
|
||||||
averageOrderValue: number;
|
productsInStock: number;
|
||||||
|
deadStockProducts: number;
|
||||||
|
deadStockValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Analytics() {
|
export function Analytics() {
|
||||||
const { data: stats, isLoading: statsLoading } = useQuery<AnalyticsStats>({
|
const { data: summary, isLoading } = useQuery<InventorySummary>({
|
||||||
queryKey: ['analytics-stats'],
|
queryKey: ['inventory-summary'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/analytics/stats`);
|
const response = await fetch(`${config.apiUrl}/analytics/inventory-summary`);
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Failed to fetch inventory summary');
|
||||||
throw new Error('Failed to fetch analytics stats');
|
|
||||||
}
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (statsLoading || !stats) {
|
|
||||||
return <div className="p-8">Loading analytics...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="flex-1 space-y-4 p-8 pt-6">
|
<motion.div layout className="flex-1 space-y-6 p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Analytics</h2>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">Stock Investment</CardTitle>
|
||||||
Overall Profit Margin
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
{isLoading || !summary ? (
|
||||||
{stats.profitMargin.toFixed(1)}%
|
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">{formatCurrency(summary.stockInvestment)}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatCurrency(summary.onOrderValue)} on order
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">Inventory Turns</CardTitle>
|
||||||
Average Markup
|
<RefreshCw className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
{isLoading || !summary ? (
|
||||||
{stats.averageMarkup.toFixed(1)}%
|
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">{summary.inventoryTurns.toFixed(1)}x</div>
|
||||||
|
<p className="text-xs text-muted-foreground">annualized (30d basis)</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">GMROI</CardTitle>
|
||||||
Stock Turnover Rate
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
{isLoading || !summary ? (
|
||||||
{stats.stockTurnoverRate.toFixed(2)}x
|
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="profit" className="space-y-4">
|
{/* Section 2: Inventory Value Trends */}
|
||||||
<TabsList className="grid w-full grid-cols-5 lg:w-[600px]">
|
<InventoryTrends />
|
||||||
<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">
|
{/* Section 3: ABC Portfolio Analysis */}
|
||||||
<ProfitAnalysis />
|
<PortfolioAnalysis />
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="vendors" className="space-y-4">
|
{/* Section 4: Capital Efficiency */}
|
||||||
<VendorPerformance />
|
<CapitalEfficiency />
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="stock" className="space-y-4">
|
{/* Section 5: Demand & Stock Health */}
|
||||||
<StockAnalysis />
|
<StockHealth />
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="pricing" className="space-y-4">
|
{/* Section 6: Aging & Sell-Through */}
|
||||||
<PriceAnalysis />
|
<AgingSellThrough />
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="categories" className="space-y-4">
|
{/* Section 7: Reorder Risk */}
|
||||||
<CategoryPerformance />
|
<StockoutRisk />
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
{/* Section 8: Discount Impact */}
|
||||||
|
<DiscountImpact />
|
||||||
|
|
||||||
|
{/* Section 9: YoY Growth Momentum */}
|
||||||
|
<GrowthMomentum />
|
||||||
</motion.div>
|
</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