diff --git a/inventory-server/src/routes/analytics.js b/inventory-server/src/routes/analytics.js index a1afc52..864b07f 100644 --- a/inventory-server/src/routes/analytics.js +++ b/inventory-server/src/routes/analytics.js @@ -181,7 +181,7 @@ router.get('/inventory-summary', async (req, res) => { END AS inventory_turns_annualized, CASE WHEN SUM(avg_stock_cost_30d) > 0 - THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) + THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12 ELSE 0 END AS gmroi, CASE @@ -314,7 +314,7 @@ router.get('/efficiency', async (req, res) => { SUM(revenue_30d) AS revenue_30d, CASE WHEN SUM(avg_stock_cost_30d) > 0 - THEN SUM(profit_30d) / SUM(avg_stock_cost_30d) + THEN (SUM(profit_30d) / SUM(avg_stock_cost_30d)) * 12 ELSE 0 END AS gmroi FROM product_metrics @@ -684,4 +684,126 @@ router.get('/growth', async (req, res) => { } }); +// Inventory value over time (uses stock_snapshots — full product coverage) +router.get('/inventory-value', async (req, res) => { + try { + const pool = req.app.locals.pool; + const period = parseInt(req.query.period) || 90; + const validPeriods = [30, 90, 365]; + const days = validPeriods.includes(period) ? period : 90; + + const { rows } = await pool.query(` + SELECT + snapshot_date AS date, + ROUND(SUM(stock_value)::numeric, 0) AS total_value, + COUNT(DISTINCT pid) AS product_count + FROM stock_snapshots + WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1) + GROUP BY snapshot_date + ORDER BY snapshot_date + `, [days]); + + res.json(rows.map(r => ({ + date: r.date, + totalValue: Number(r.total_value) || 0, + productCount: Number(r.product_count) || 0, + }))); + } catch (error) { + console.error('Error fetching inventory value:', error); + res.status(500).json({ error: 'Failed to fetch inventory value' }); + } +}); + +// Inventory flow: receiving vs selling per day +router.get('/flow', async (req, res) => { + try { + const pool = req.app.locals.pool; + const period = parseInt(req.query.period) || 30; + const validPeriods = [30, 90]; + const days = validPeriods.includes(period) ? period : 30; + + const { rows } = await pool.query(` + SELECT + snapshot_date AS date, + COALESCE(SUM(units_received), 0) AS units_received, + ROUND(COALESCE(SUM(cost_received), 0)::numeric, 0) AS cost_received, + COALESCE(SUM(units_sold), 0) AS units_sold, + ROUND(COALESCE(SUM(cogs), 0)::numeric, 0) AS cogs_sold + FROM daily_product_snapshots + WHERE snapshot_date >= CURRENT_DATE - make_interval(days => $1) + GROUP BY snapshot_date + ORDER BY snapshot_date + `, [days]); + + res.json(rows.map(r => ({ + date: r.date, + unitsReceived: Number(r.units_received) || 0, + costReceived: Number(r.cost_received) || 0, + unitsSold: Number(r.units_sold) || 0, + cogsSold: Number(r.cogs_sold) || 0, + }))); + } catch (error) { + console.error('Error fetching inventory flow:', error); + res.status(500).json({ error: 'Failed to fetch inventory flow' }); + } +}); + +// Seasonal pattern distribution +router.get('/seasonal', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: patterns } = await pool.query(` + SELECT + COALESCE(seasonal_pattern, 'unknown') AS pattern, + COUNT(*) AS product_count, + SUM(current_stock_cost) AS stock_cost, + SUM(revenue_30d) AS revenue + FROM product_metrics + WHERE is_visible = true + AND current_stock > 0 + GROUP BY seasonal_pattern + ORDER BY COUNT(*) DESC + `); + + const { rows: peakSeasons } = await pool.query(` + SELECT + peak_season AS month, + COUNT(*) AS product_count, + SUM(current_stock_cost) AS stock_cost + FROM product_metrics + WHERE is_visible = true + AND current_stock > 0 + AND seasonal_pattern IN ('moderate', 'strong') + AND peak_season IS NOT NULL + GROUP BY peak_season + ORDER BY + CASE peak_season + WHEN 'January' THEN 1 WHEN 'February' THEN 2 WHEN 'March' THEN 3 + WHEN 'April' THEN 4 WHEN 'May' THEN 5 WHEN 'June' THEN 6 + WHEN 'July' THEN 7 WHEN 'August' THEN 8 WHEN 'September' THEN 9 + WHEN 'October' THEN 10 WHEN 'November' THEN 11 WHEN 'December' THEN 12 + ELSE 13 + END + `); + + res.json({ + patterns: patterns.map(r => ({ + pattern: r.pattern, + productCount: Number(r.product_count) || 0, + stockCost: Number(r.stock_cost) || 0, + revenue: Number(r.revenue) || 0, + })), + peakSeasons: peakSeasons.map(r => ({ + month: r.month, + productCount: Number(r.product_count) || 0, + stockCost: Number(r.stock_cost) || 0, + })), + }); + } catch (error) { + console.error('Error fetching seasonal data:', error); + res.status(500).json({ error: 'Failed to fetch seasonal data' }); + } +}); + module.exports = router; diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index 0c272c8..8f6f838 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -1185,4 +1185,67 @@ router.get('/delivery-metrics', async (req, res) => { } }); -module.exports = router; \ No newline at end of file +// PO Pipeline — expected arrivals timeline + overdue summary +router.get('/pipeline', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Expected arrivals by week (ordered + electronically_sent with expected_date) + const { rows: arrivals } = await pool.query(` + SELECT + DATE_TRUNC('week', expected_date)::date AS week, + COUNT(DISTINCT po_id) AS po_count, + ROUND(SUM(po_cost_price * ordered)::numeric, 0) AS expected_value, + COUNT(DISTINCT vendor) AS vendor_count + FROM purchase_orders + WHERE status IN ('ordered', 'electronically_sent') + AND expected_date IS NOT NULL + GROUP BY 1 + ORDER BY 1 + `); + + // Overdue POs (expected_date in the past) + const { rows: [overdue] } = await pool.query(` + SELECT + COUNT(DISTINCT po_id) AS po_count, + ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_value + FROM purchase_orders + WHERE status IN ('ordered', 'electronically_sent') + AND expected_date IS NOT NULL + AND expected_date < CURRENT_DATE + `); + + // Summary: all open POs + const { rows: [summary] } = await pool.query(` + SELECT + COUNT(DISTINCT po_id) AS total_open_pos, + ROUND(COALESCE(SUM(po_cost_price * ordered), 0)::numeric, 0) AS total_on_order_value, + COUNT(DISTINCT vendor) AS vendor_count + FROM purchase_orders + WHERE status IN ('ordered', 'electronically_sent') + `); + + res.json({ + arrivals: arrivals.map(r => ({ + week: r.week, + poCount: Number(r.po_count) || 0, + expectedValue: Number(r.expected_value) || 0, + vendorCount: Number(r.vendor_count) || 0, + })), + overdue: { + count: Number(overdue.po_count) || 0, + value: Number(overdue.total_value) || 0, + }, + summary: { + totalOpenPOs: Number(summary.total_open_pos) || 0, + totalOnOrderValue: Number(summary.total_on_order_value) || 0, + vendorCount: Number(summary.vendor_count) || 0, + }, + }); + } catch (error) { + console.error('Error fetching PO pipeline:', error); + res.status(500).json({ error: 'Failed to fetch PO pipeline' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory/src/components/analytics/CapitalEfficiency.tsx b/inventory/src/components/analytics/CapitalEfficiency.tsx index e3a2592..b11fc6d 100644 --- a/inventory/src/components/analytics/CapitalEfficiency.tsx +++ b/inventory/src/components/analytics/CapitalEfficiency.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { @@ -32,12 +33,15 @@ interface EfficiencyData { } function getGmroiColor(gmroi: number): string { - if (gmroi >= 1) return METRIC_COLORS.revenue; // emerald — good - if (gmroi >= 0.3) return METRIC_COLORS.comparison; // amber — ok + if (gmroi >= 3) return METRIC_COLORS.revenue; // emerald — strong + if (gmroi >= 1) return METRIC_COLORS.comparison; // amber — acceptable return '#ef4444'; // red — poor } +type GmroiView = 'top' | 'bottom'; + export function CapitalEfficiency() { + const [gmroiView, setGmroiView] = useState('top'); const { data, isLoading, isError } = useQuery({ queryKey: ['capital-efficiency'], queryFn: async () => { @@ -73,17 +77,38 @@ export function CapitalEfficiency() { ); } - // Top 15 by GMROI for bar chart - const sortedGmroi = [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15); + // Top or bottom 15 by GMROI for bar chart + const sortedGmroi = gmroiView === 'top' + ? [...data.vendors].sort((a, b) => b.gmroi - a.gmroi).slice(0, 15) + : [...data.vendors].sort((a, b) => a.gmroi - b.gmroi).slice(0, 15); return (
- GMROI by Vendor -

- Gross margin return on inventory investment (top vendors by stock value) -

+
+
+ GMROI by Vendor +

+ Annualized gross margin return on investment (top 30 vendors by stock value) +

+
+
+ {(['top', 'bottom'] as GmroiView[]).map((v) => ( + + ))} +
+
@@ -112,7 +137,7 @@ export function CapitalEfficiency() { ); }} /> - + {sortedGmroi.map((entry, i) => ( diff --git a/inventory/src/components/analytics/InventoryFlow.tsx b/inventory/src/components/analytics/InventoryFlow.tsx new file mode 100644 index 0000000..302745e --- /dev/null +++ b/inventory/src/components/analytics/InventoryFlow.tsx @@ -0,0 +1,186 @@ +import { useState, useMemo } 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, + Legend, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; +import { ArrowDownToLine, ArrowUpFromLine, TrendingUp } from 'lucide-react'; + +interface FlowPoint { + date: string; + unitsReceived: number; + costReceived: number; + unitsSold: number; + cogsSold: number; +} + +type Period = 30 | 90; + +function formatDate(dateStr: string, period: Period): string { + const d = new Date(dateStr); + if (period === 90) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export function InventoryFlow() { + const [period, setPeriod] = useState(30); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['inventory-flow', period], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/flow?period=${period}`); + if (!response.ok) throw new Error('Failed to fetch inventory flow'); + return response.json(); + }, + }); + + const totals = useMemo(() => { + if (!data) return { received: 0, sold: 0, net: 0 }; + const received = data.reduce((s, d) => s + d.costReceived, 0); + const sold = data.reduce((s, d) => s + d.cogsSold, 0); + return { received, sold, net: received - sold }; + }, [data]); + + const chartData = useMemo(() => { + if (!data) return []; + return data.map(d => ({ + ...d, + netFlow: d.costReceived - d.cogsSold, + })); + }, [data]); + + return ( + + +
+
+ Inventory Flow: Receiving vs Selling +

+ Daily cost of goods received vs cost of goods sold +

+
+
+ {([30, 90] as Period[]).map((p) => ( + + ))} +
+
+
+ + {isError ? ( +
+

Failed to load flow data

+
+ ) : isLoading || !data ? ( +
+
Loading flow data...
+
+ ) : ( +
+ {/* Summary stats */} +
+
+
+ +
+
+

Total Received

+

{formatCurrency(totals.received)}

+
+
+
+
+ +
+
+

Total Sold (COGS)

+

{formatCurrency(totals.sold)}

+
+
+
+
= 0 ? 'bg-amber-500/10' : 'bg-green-500/10'}`}> + = 0 ? 'text-amber-500' : 'text-green-500'}`} /> +
+
+

Net Change

+

= 0 ? 'text-amber-600' : 'text-green-600'}`}> + {totals.net >= 0 ? '+' : ''}{formatCurrency(totals.net)} +

+

+ {totals.net >= 0 ? 'inventory growing' : 'inventory shrinking'} +

+
+
+
+ + {/* Chart */} + + + + formatDate(v, period)} + tick={{ fontSize: 12 }} + interval={period === 90 ? 6 : 2} + /> + + new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })} + formatter={(value: number, name: string) => [formatCurrency(value), name]} + /> + + + + + + +
+ )} +
+
+ ); +} diff --git a/inventory/src/components/analytics/InventoryValueTrend.tsx b/inventory/src/components/analytics/InventoryValueTrend.tsx new file mode 100644 index 0000000..2cc6905 --- /dev/null +++ b/inventory/src/components/analytics/InventoryValueTrend.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + Area, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Line, + ComposedChart, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; + +interface ValuePoint { + date: string; + totalValue: number; + productCount: 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 InventoryValueTrend() { + const [period, setPeriod] = useState(90); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['inventory-value', period], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/inventory-value?period=${period}`); + if (!response.ok) throw new Error('Failed to fetch inventory value'); + return response.json(); + }, + }); + + const latest = data?.[data.length - 1]; + const earliest = data?.[0]; + const change = latest && earliest ? latest.totalValue - earliest.totalValue : 0; + const changePct = earliest && earliest.totalValue > 0 + ? ((change / earliest.totalValue) * 100).toFixed(1) + : '0'; + + return ( + + +
+
+ Inventory Value Over Time +

+ Total stock investment (cost) with product count overlay + {latest && ( + + — Current: {formatCurrency(latest.totalValue)} + {' '} + = 0 ? 'text-green-600' : 'text-red-600'}> + ({change >= 0 ? '+' : ''}{changePct}%) + + + )} +

+
+
+ {([30, 90, 365] as Period[]).map((p) => ( + + ))} +
+
+
+ + {isError ? ( +
+

Failed to load inventory value data

+
+ ) : isLoading || !data ? ( +
+
Loading inventory value...
+
+ ) : ( + + + + + + + + + + formatDate(v, period)} + tick={{ fontSize: 12 }} + interval={period === 365 ? 29 : period === 90 ? 6 : 2} + /> + + + new Date(v).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })} + formatter={(value: number, name: string) => [ + name === 'Stock Value' ? formatCurrency(value) : value.toLocaleString(), + name, + ]} + /> + + + + + )} +
+
+ ); +} diff --git a/inventory/src/components/analytics/SeasonalPatterns.tsx b/inventory/src/components/analytics/SeasonalPatterns.tsx new file mode 100644 index 0000000..e0e41dd --- /dev/null +++ b/inventory/src/components/analytics/SeasonalPatterns.tsx @@ -0,0 +1,240 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + ResponsiveContainer, + PieChart, + Pie, + Cell, + Legend, + Tooltip, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from 'recharts'; +import config from '../../config'; +import { METRIC_COLORS } from '@/lib/dashboard/designTokens'; +import { formatCurrency } from '@/utils/formatCurrency'; +import { Sun, Snowflake } from 'lucide-react'; + +interface PatternRow { + pattern: string; + productCount: number; + stockCost: number; + revenue: number; +} + +interface PeakSeasonRow { + month: string; + productCount: number; + stockCost: number; +} + +interface SeasonalData { + patterns: PatternRow[]; + peakSeasons: PeakSeasonRow[]; +} + +const PATTERN_COLORS: Record = { + none: '#94a3b8', // slate — no seasonality + moderate: METRIC_COLORS.comparison, // amber + strong: METRIC_COLORS.revenue, // emerald + unknown: '#cbd5e1', // light slate +}; + +const PATTERN_LABELS: Record = { + none: 'No Seasonality', + moderate: 'Moderate', + strong: 'Strong', + unknown: 'Unknown', +}; + +export function SeasonalPatterns() { + const { data, isLoading, isError } = useQuery({ + queryKey: ['seasonal-patterns'], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/analytics/seasonal`); + if (!response.ok) throw new Error('Failed to fetch seasonal data'); + return response.json(); + }, + }); + + if (isError) { + return ( + + Seasonal Patterns + +
+

Failed to load seasonal data

+
+
+
+ ); + } + + if (isLoading || !data) { + return ( + + Seasonal Patterns + +
+
Loading seasonal data...
+
+
+
+ ); + } + + const seasonal = data.patterns.filter(p => p.pattern === 'moderate' || p.pattern === 'strong'); + const seasonalCount = seasonal.reduce((s, p) => s + p.productCount, 0); + const seasonalStockCost = seasonal.reduce((s, p) => s + p.stockCost, 0); + const totalProducts = data.patterns.reduce((s, p) => s + p.productCount, 0); + const seasonalPct = totalProducts > 0 ? ((seasonalCount / totalProducts) * 100).toFixed(0) : '0'; + + const donutData = data.patterns.map(p => ({ + name: PATTERN_LABELS[p.pattern] || p.pattern, + value: p.productCount, + color: PATTERN_COLORS[p.pattern] || '#94a3b8', + stockCost: p.stockCost, + revenue: p.revenue, + })); + + return ( +
+ {/* Summary cards */} +
+ + +
+ +
+
+

Seasonal Products

+

{seasonalCount.toLocaleString()}

+

{seasonalPct}% of in-stock products

+
+
+
+ + +
+ +
+
+

Seasonal Stock Value

+

{formatCurrency(seasonalStockCost)}

+

capital in seasonal items

+
+
+
+ + +
+ +
+
+

Peak Months Tracked

+

{data.peakSeasons.length}

+

months with seasonal peaks

+
+
+
+
+ +
+ {/* Donut chart */} + + + Demand Seasonality Distribution +

+ Products by seasonal demand pattern (in-stock only) +

+
+ + + + `${name} (${value.toLocaleString()})`} + > + {donutData.map((entry, i) => ( + + ))} + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload; + return ( +
+

{d.name}

+

{d.value.toLocaleString()} products

+

Stock value: {formatCurrency(d.stockCost)}

+

Revenue (30d): {formatCurrency(d.revenue)}

+
+ ); + }} + /> + {value}} + /> +
+
+
+
+ + {/* Peak season bar chart */} + + + Peak Season Distribution +

+ Which months seasonal products peak (moderate + strong patterns) +

+
+ + {data.peakSeasons.length === 0 ? ( +
+

No peak season data available

+
+ ) : ( + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as PeakSeasonRow; + return ( +
+

{d.month}

+

{d.productCount.toLocaleString()} seasonal products peak

+

Stock value: {formatCurrency(d.stockCost)}

+
+ ); + }} + /> + +
+
+ )} +
+
+
+
+ ); +} diff --git a/inventory/src/components/purchase-orders/PipelineCard.tsx b/inventory/src/components/purchase-orders/PipelineCard.tsx new file mode 100644 index 0000000..b73cde7 --- /dev/null +++ b/inventory/src/components/purchase-orders/PipelineCard.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; +import { + ResponsiveContainer, + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + CartesianGrid, + Cell, +} from 'recharts'; +import { AlertTriangle, Package, Clock } from 'lucide-react'; + +interface Arrival { + week: string; + poCount: number; + expectedValue: number; + vendorCount: number; +} + +interface PipelineData { + arrivals: Arrival[]; + overdue: { count: number; value: number }; + summary: { totalOpenPOs: number; totalOnOrderValue: number; vendorCount: number }; +} + +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 `$${Math.round(value)}`; +} + +function formatWeek(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export default function PipelineCard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch('/api/purchase-orders/pipeline') + .then(res => res.ok ? res.json() : Promise.reject('Failed')) + .then(setData) + .catch(err => console.error('Pipeline fetch error:', err)) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( +
+

Incoming Pipeline

+
+
Loading pipeline...
+
+
+ ); + } + + if (!data) { + return ( +
+

Incoming Pipeline

+

Failed to load pipeline data

+
+ ); + } + + const now = new Date(); + const currentWeekStart = new Date(now); + currentWeekStart.setDate(now.getDate() - now.getDay() + 1); // Monday + const currentWeekStr = currentWeekStart.toISOString().split('T')[0]; + + // Split arrivals into overdue vs upcoming + const chartData = data.arrivals.map(a => ({ + ...a, + label: formatWeek(a.week), + isOverdue: new Date(a.week) < new Date(currentWeekStr), + })); + + return ( +
+
+
+

Incoming Pipeline

+

Expected PO arrivals by week

+
+
+ + {/* Summary stats row */} +
+
+ +
+

Open POs

+

{data.summary.totalOpenPOs}

+
+
+
+ +
+

On Order

+

{formatCurrency(data.summary.totalOnOrderValue)}

+
+
+ {data.overdue.count > 0 ? ( +
+ +
+

Overdue

+

+ {data.overdue.count} POs ({formatCurrency(data.overdue.value)}) +

+
+
+ ) : ( +
+ +
+

Overdue

+

None

+
+
+ )} +
+ + {/* Arrivals chart */} + {chartData.length === 0 ? ( +
+

No expected arrivals scheduled

+
+ ) : ( + + + + + + { + if (!active || !payload?.length) return null; + const d = payload[0].payload as Arrival & { isOverdue: boolean }; + return ( +
+

+ Week of {formatWeek(d.week)} + {d.isOverdue && (overdue)} +

+

{d.poCount} purchase orders

+

Expected value: {formatCurrency(d.expectedValue)}

+

{d.vendorCount} vendors

+
+ ); + }} + /> + + {chartData.map((entry, i) => ( + + ))} + +
+
+ )} +
+ ); +} diff --git a/inventory/src/pages/Analytics.tsx b/inventory/src/pages/Analytics.tsx index c6e7bf7..b00ba38 100644 --- a/inventory/src/pages/Analytics.tsx +++ b/inventory/src/pages/Analytics.tsx @@ -1,11 +1,14 @@ import { useQuery } from '@tanstack/react-query'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; +import { InventoryValueTrend } from '../components/analytics/InventoryValueTrend'; +import { InventoryFlow } from '../components/analytics/InventoryFlow'; import { InventoryTrends } from '../components/analytics/InventoryTrends'; import { PortfolioAnalysis } from '../components/analytics/PortfolioAnalysis'; import { CapitalEfficiency } from '../components/analytics/CapitalEfficiency'; import { StockHealth } from '../components/analytics/StockHealth'; import { AgingSellThrough } from '../components/analytics/AgingSellThrough'; import { StockoutRisk } from '../components/analytics/StockoutRisk'; +import { SeasonalPatterns } from '../components/analytics/SeasonalPatterns'; import { DiscountImpact } from '../components/analytics/DiscountImpact'; import { GrowthMomentum } from '../components/analytics/GrowthMomentum'; import config from '../config'; @@ -95,7 +98,7 @@ export function Analytics() { <>
{summary.gmroi.toFixed(2)}

- profit per $ invested (30d) + annualized profit per $ invested

)} @@ -121,28 +124,37 @@ export function Analytics() {
- {/* Section 2: Inventory Value Trends */} + {/* Section 2: Inventory Value Over Time */} + + + {/* Section 3: Inventory Flow — Receiving vs Selling */} + + + {/* Section 4: Daily Sales Activity & Stockouts */} - {/* Section 3: ABC Portfolio Analysis */} + {/* Section 5: ABC Portfolio Analysis */} - {/* Section 4: Capital Efficiency */} + {/* Section 6: Capital Efficiency */} - {/* Section 5: Demand & Stock Health */} + {/* Section 7: Demand & Stock Health */} - {/* Section 6: Aging & Sell-Through */} + {/* Section 8: Aging & Sell-Through */} - {/* Section 7: Reorder Risk */} + {/* Section 9: Reorder Risk */} - {/* Section 8: Discount Impact */} + {/* Section 10: Seasonal Patterns */} + + + {/* Section 11: Discount Impact */} - {/* Section 9: YoY Growth Momentum */} + {/* Section 12: YoY Growth Momentum */} ); diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index 4c157f0..152b863 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -5,6 +5,7 @@ import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCa import PaginationControls from "../components/purchase-orders/PaginationControls"; import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable"; import FilterControls from "../components/purchase-orders/FilterControls"; +import PipelineCard from "../components/purchase-orders/PipelineCard"; interface PurchaseOrder { id: number | string; @@ -450,6 +451,10 @@ export default function PurchaseOrders() { /> +
+ +
+