Add platform growth analytics tab with charts
Introduces a new 'Growth Since Launch' tab to the admin analytics page, displaying cumulative stats, monthly revenue and orders, customer segment breakdowns, profit trends, and a detailed monthly growth table. Fetches and visualizes growth data since platform launch using various Recharts components.
This commit is contained in:
@@ -44,6 +44,77 @@ import {
|
|||||||
ComposedChart,
|
ComposedChart,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { formatGBP } from "@/utils/format";
|
import { formatGBP } from "@/utils/format";
|
||||||
|
import { PieChart, Pie, Cell, Legend, AreaChart, Area } from "recharts";
|
||||||
|
|
||||||
|
interface GrowthData {
|
||||||
|
launchDate: string;
|
||||||
|
generatedAt: string;
|
||||||
|
daily: Array<{
|
||||||
|
date: string;
|
||||||
|
orders: number;
|
||||||
|
revenue: number;
|
||||||
|
customers: number;
|
||||||
|
avgOrderValue: number;
|
||||||
|
}>;
|
||||||
|
monthly: Array<{
|
||||||
|
month: string;
|
||||||
|
orders: number;
|
||||||
|
revenue: number;
|
||||||
|
customers: number;
|
||||||
|
avgOrderValue: number;
|
||||||
|
newVendors: number;
|
||||||
|
newCustomers: number;
|
||||||
|
}>;
|
||||||
|
customers: {
|
||||||
|
total: number;
|
||||||
|
segments: {
|
||||||
|
new: number;
|
||||||
|
returning: number;
|
||||||
|
loyal: number;
|
||||||
|
vip: number;
|
||||||
|
};
|
||||||
|
segmentDetails: {
|
||||||
|
[key: string]: {
|
||||||
|
count: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
avgOrderCount: number;
|
||||||
|
avgSpent: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
segmentPercentages: {
|
||||||
|
new: number;
|
||||||
|
returning: number;
|
||||||
|
loyal: number;
|
||||||
|
vip: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
profit: {
|
||||||
|
monthly: Array<{
|
||||||
|
month: string;
|
||||||
|
revenue: number;
|
||||||
|
trackedRevenue: number;
|
||||||
|
cost: number;
|
||||||
|
profit: number;
|
||||||
|
profitMargin: number;
|
||||||
|
costDataCoverage: number;
|
||||||
|
}>;
|
||||||
|
totals: {
|
||||||
|
revenue: number;
|
||||||
|
trackedRevenue: number;
|
||||||
|
cost: number;
|
||||||
|
profit: number;
|
||||||
|
profitMargin: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
cumulative: {
|
||||||
|
orders: number;
|
||||||
|
revenue: number;
|
||||||
|
customers: number;
|
||||||
|
vendors: number;
|
||||||
|
products: number;
|
||||||
|
avgOrderValue: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
meta?: {
|
meta?: {
|
||||||
@@ -123,8 +194,18 @@ export default function AdminAnalytics() {
|
|||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [showDebug, setShowDebug] = useState(false);
|
const [showDebug, setShowDebug] = useState(false);
|
||||||
|
const [growthData, setGrowthData] = useState<GrowthData | null>(null);
|
||||||
|
const [growthLoading, setGrowthLoading] = useState(false);
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Segment colors for pie chart
|
||||||
|
const SEGMENT_COLORS = {
|
||||||
|
new: "#3b82f6", // blue
|
||||||
|
returning: "#10b981", // green
|
||||||
|
loyal: "#f59e0b", // amber
|
||||||
|
vip: "#8b5cf6", // purple
|
||||||
|
};
|
||||||
const isViewingCurrentYear = selectedYear === currentYear;
|
const isViewingCurrentYear = selectedYear === currentYear;
|
||||||
|
|
||||||
const fetchAnalyticsData = async () => {
|
const fetchAnalyticsData = async () => {
|
||||||
@@ -161,11 +242,34 @@ export default function AdminAnalytics() {
|
|||||||
fetchAnalyticsData();
|
fetchAnalyticsData();
|
||||||
}, [dateRange, selectedYear]);
|
}, [dateRange, selectedYear]);
|
||||||
|
|
||||||
|
// Fetch growth data (cached, since launch)
|
||||||
|
const fetchGrowthData = async (forceRefresh = false) => {
|
||||||
|
try {
|
||||||
|
setGrowthLoading(true);
|
||||||
|
const url = forceRefresh ? "/admin/growth?refresh=true" : "/admin/growth";
|
||||||
|
const data = await fetchClient<GrowthData>(url);
|
||||||
|
setGrowthData(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching growth data:", error);
|
||||||
|
} finally {
|
||||||
|
setGrowthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch growth data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchGrowthData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchAnalyticsData();
|
fetchAnalyticsData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGrowthRefresh = () => {
|
||||||
|
fetchGrowthData(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading && !analyticsData) {
|
if (loading && !analyticsData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center my-8">
|
<div className="flex justify-center my-8">
|
||||||
@@ -820,6 +924,7 @@ export default function AdminAnalytics() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="orders">Orders</TabsTrigger>
|
<TabsTrigger value="orders">Orders</TabsTrigger>
|
||||||
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
<TabsTrigger value="vendors">Vendors</TabsTrigger>
|
||||||
|
<TabsTrigger value="growth">Growth Since Launch</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="orders" className="mt-4">
|
<TabsContent value="orders" className="mt-4">
|
||||||
@@ -1072,6 +1177,496 @@ export default function AdminAnalytics() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="growth" className="mt-4 space-y-6">
|
||||||
|
{/* Growth Header */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Platform Growth Since Launch
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{growthData?.launchDate
|
||||||
|
? `Tracking since ${new Date(growthData.launchDate).toLocaleDateString("en-GB", { month: "long", year: "numeric" })}`
|
||||||
|
: "February 2025"}
|
||||||
|
{growthData?.generatedAt && (
|
||||||
|
<span className="ml-2">
|
||||||
|
• Last updated:{" "}
|
||||||
|
{new Date(growthData.generatedAt).toLocaleTimeString(
|
||||||
|
"en-GB",
|
||||||
|
{ hour: "2-digit", minute: "2-digit" },
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGrowthRefresh}
|
||||||
|
disabled={growthLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 mr-2 ${growthLoading ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cumulative Stats Cards */}
|
||||||
|
{growthData?.cumulative && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Orders
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{growthData.cumulative.orders.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Revenue
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{formatCurrency(growthData.cumulative.revenue)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Customers
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{growthData.cumulative.customers.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Vendors
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{growthData.cumulative.vendors.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Products
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{growthData.cumulative.products.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg Order Value
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{formatCurrency(growthData.cumulative.avgOrderValue)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly Revenue & Orders Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Revenue & Orders</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Platform performance by month since launch
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{growthLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-80">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
) : growthData?.monthly && growthData.monthly.length > 0 ? (
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart
|
||||||
|
data={growthData.monthly.map((m) => ({
|
||||||
|
...m,
|
||||||
|
formattedMonth: new Date(
|
||||||
|
m.month + "-01",
|
||||||
|
).toLocaleDateString("en-GB", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="formattedMonth" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis yAxisId="left" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
`£${(value / 1000).toFixed(0)}k`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium mb-2">{data.month}</p>
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
Orders: {data.orders.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-purple-600">
|
||||||
|
Customers: {data.customers.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-amber-600">
|
||||||
|
New Vendors: {data.newVendors}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
yAxisId="left"
|
||||||
|
dataKey="orders"
|
||||||
|
fill="#3b82f6"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
name="Orders"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
yAxisId="right"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="revenue"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ fill: "#10b981", r: 4 }}
|
||||||
|
name="Revenue"
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-80 text-muted-foreground">
|
||||||
|
No growth data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Customer Segments Pie Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Customer Segments</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Breakdown by purchase behavior
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{growthLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
) : growthData?.customers ? (
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
name: "New (1 order)",
|
||||||
|
value: growthData.customers.segments.new,
|
||||||
|
color: SEGMENT_COLORS.new,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Returning (2+)",
|
||||||
|
value: growthData.customers.segments.returning,
|
||||||
|
color: SEGMENT_COLORS.returning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Loyal (£300+/4+)",
|
||||||
|
value: growthData.customers.segments.loyal,
|
||||||
|
color: SEGMENT_COLORS.loyal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VIP (£1k+/10+)",
|
||||||
|
value: growthData.customers.segments.vip,
|
||||||
|
color: SEGMENT_COLORS.vip,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) =>
|
||||||
|
`${name}: ${(percent * 100).toFixed(0)}%`
|
||||||
|
}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ color: SEGMENT_COLORS.new },
|
||||||
|
{ color: SEGMENT_COLORS.returning },
|
||||||
|
{ color: SEGMENT_COLORS.loyal },
|
||||||
|
{ color: SEGMENT_COLORS.vip },
|
||||||
|
].map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
const details =
|
||||||
|
growthData.customers.segmentDetails[
|
||||||
|
data.name.split(" ")[0].toLowerCase()
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium">{data.name}</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Count: {data.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
{details && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-green-600">
|
||||||
|
Revenue:{" "}
|
||||||
|
{formatCurrency(details.totalRevenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Avg Orders: {details.avgOrderCount}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
No customer data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Segment Stats */}
|
||||||
|
{growthData?.customers && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-4">
|
||||||
|
<div className="p-2 rounded bg-blue-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-blue-600">
|
||||||
|
{growthData.customers.segments.new}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">New</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-green-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-green-600">
|
||||||
|
{growthData.customers.segments.returning}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Returning
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-amber-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-amber-600">
|
||||||
|
{growthData.customers.segments.loyal}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Loyal</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-purple-500/10 text-center">
|
||||||
|
<div className="text-lg font-bold text-purple-600">
|
||||||
|
{growthData.customers.segments.vip}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">VIP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly Profit Chart */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Profit Trends</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Profit margins over time
|
||||||
|
{growthData?.profit?.totals && (
|
||||||
|
<span className="ml-2 text-green-600 font-medium">
|
||||||
|
(Total: {formatCurrency(growthData.profit.totals.profit)}{" "}
|
||||||
|
at {growthData.profit.totals.profitMargin}% margin)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{growthLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
) : growthData?.profit?.monthly &&
|
||||||
|
growthData.profit.monthly.length > 0 ? (
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart
|
||||||
|
data={growthData.profit.monthly.map((m) => ({
|
||||||
|
...m,
|
||||||
|
formattedMonth: new Date(
|
||||||
|
m.month + "-01",
|
||||||
|
).toLocaleDateString("en-GB", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
}),
|
||||||
|
}))}
|
||||||
|
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="formattedMonth"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) =>
|
||||||
|
`£${(value / 1000).toFixed(0)}k`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium mb-2">
|
||||||
|
{data.month}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-blue-600">
|
||||||
|
Revenue: {formatCurrency(data.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Cost: {formatCurrency(data.cost)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-green-600 font-medium">
|
||||||
|
Profit: {formatCurrency(data.profit)}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-purple-600">
|
||||||
|
Margin: {data.profitMargin}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Cost data coverage: {data.costDataCoverage}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="profit"
|
||||||
|
stroke="#10b981"
|
||||||
|
fill="#10b981"
|
||||||
|
fillOpacity={0.3}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||||
|
No profit data available (add costPerUnit to products)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Growth Table */}
|
||||||
|
{growthData?.monthly && growthData.monthly.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Monthly Breakdown</CardTitle>
|
||||||
|
<CardDescription>Detailed metrics by month</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="text-left p-2 font-medium">Month</th>
|
||||||
|
<th className="text-right p-2 font-medium">Orders</th>
|
||||||
|
<th className="text-right p-2 font-medium">Revenue</th>
|
||||||
|
<th className="text-right p-2 font-medium">
|
||||||
|
Customers
|
||||||
|
</th>
|
||||||
|
<th className="text-right p-2 font-medium">
|
||||||
|
Avg Order
|
||||||
|
</th>
|
||||||
|
<th className="text-right p-2 font-medium">
|
||||||
|
New Vendors
|
||||||
|
</th>
|
||||||
|
<th className="text-right p-2 font-medium">
|
||||||
|
New Customers
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{growthData.monthly.map((month) => (
|
||||||
|
<tr
|
||||||
|
key={month.month}
|
||||||
|
className="border-b hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<td className="p-2 font-medium">
|
||||||
|
{new Date(month.month + "-01").toLocaleDateString(
|
||||||
|
"en-GB",
|
||||||
|
{ month: "long", year: "numeric" },
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{month.orders.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2 text-green-600">
|
||||||
|
{formatCurrency(month.revenue)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{month.customers.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{formatCurrency(month.avgOrderValue)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2">{month.newVendors}</td>
|
||||||
|
<td className="text-right p-2">
|
||||||
|
{month.newCustomers}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user