Add profit analytics chart and service
Introduces a new ProfitAnalyticsChart component to display profit-related metrics, including total revenue, cost, profit, and top profitable products. Updates the AnalyticsDashboard to include a Profit tab and adds a profit-analytics-service for fetching profit data from the backend.
This commit is contained in:
@@ -17,7 +17,8 @@ import {
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff
|
||||
EyeOff,
|
||||
Calculator
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import MetricsCard from "./MetricsCard";
|
||||
@@ -44,6 +45,10 @@ const OrderAnalyticsChart = dynamic(() => import('./OrderAnalyticsChart'), {
|
||||
loading: () => <ChartSkeleton />
|
||||
});
|
||||
|
||||
const ProfitAnalyticsChart = dynamic(() => import('./ProfitAnalyticsChart'), {
|
||||
loading: () => <ChartSkeleton />
|
||||
});
|
||||
|
||||
// Chart loading skeleton
|
||||
function ChartSkeleton() {
|
||||
return (
|
||||
@@ -249,7 +254,7 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Time Period</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Revenue and Orders tabs use time filtering. Products and Customers show all-time data.
|
||||
Revenue, Profit, and Orders tabs use time filtering. Products and Customers show all-time data.
|
||||
</p>
|
||||
</div>
|
||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||
@@ -267,11 +272,15 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
||||
{/* Analytics Tabs */}
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="revenue" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="revenue" className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Revenue
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="profit" className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4" />
|
||||
Profit
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="products" className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Products
|
||||
@@ -292,6 +301,12 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="profit" className="space-y-6">
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<ProfitAnalyticsChart timeRange={timeRange} hideNumbers={hideNumbers} />
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="products" className="space-y-6">
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<ProductPerformanceChart />
|
||||
|
||||
270
components/analytics/ProfitAnalyticsChart.tsx
Normal file
270
components/analytics/ProfitAnalyticsChart.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
PieChart,
|
||||
Calculator,
|
||||
Info,
|
||||
AlertTriangle
|
||||
} from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { formatGBP } from "@/utils/format";
|
||||
import { getProfitOverview, type ProfitOverview } from "@/lib/services/profit-analytics-service";
|
||||
import { TableSkeleton } from './SkeletonLoaders';
|
||||
|
||||
interface ProfitAnalyticsChartProps {
|
||||
timeRange: string;
|
||||
hideNumbers?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfitAnalyticsChart({ timeRange, hideNumbers = false }: ProfitAnalyticsChartProps) {
|
||||
const [data, setData] = useState<ProfitOverview | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const maskValue = (value: string): string => {
|
||||
if (!hideNumbers) return value;
|
||||
if (value.includes('£')) return '£***';
|
||||
if (value.match(/^\d/)) {
|
||||
const numLength = value.replace(/[,\.%]/g, '').length;
|
||||
return '*'.repeat(Math.min(numLength, 4));
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const response = await getProfitOverview(timeRange);
|
||||
setData(response);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profit data:', error);
|
||||
setError('Failed to load profit analytics');
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load profit analytics data.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [timeRange, toast]);
|
||||
|
||||
if (isLoading) {
|
||||
return <TableSkeleton />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5" />
|
||||
Profit Analytics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8">
|
||||
<AlertTriangle className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-muted-foreground">Failed to load profit data</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data.hasCostData) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5" />
|
||||
Profit Analytics
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Track your actual profits based on sales and cost data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>No cost data available</strong><br />
|
||||
Add cost prices to your products to see profit analytics. Go to Products → Edit → Cost & Profit Tracking section.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const profitDirection = data.summary.totalProfit >= 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{maskValue(formatGBP(data.summary.totalRevenue))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From {data.summary.totalProductsSold} items sold
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{maskValue(formatGBP(data.summary.totalCost))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From {data.summary.productsWithCostData} tracked items
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Profit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold flex items-center gap-2 ${
|
||||
profitDirection ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{profitDirection ? (
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
) : (
|
||||
<TrendingDown className="h-5 w-5" />
|
||||
)}
|
||||
{maskValue(formatGBP(data.summary.totalProfit))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maskValue(`${data.summary.overallProfitMargin.toFixed(1)}%`)} margin
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Profit/Unit</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{maskValue(formatGBP(data.summary.averageProfitPerUnit))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Per unit sold
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cost Data Coverage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChart className="h-5 w-5" />
|
||||
Cost Data Coverage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Percentage of sold items that have cost data for profit calculation
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl font-bold">
|
||||
{hideNumbers ? "**%" : `${data.summary.costDataCoverage.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="w-full bg-secondary rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: hideNumbers ? "0%" : `${data.summary.costDataCoverage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{hideNumbers ? "** / **" : `${data.summary.productsWithCostData} / ${data.summary.totalProductsSold}`}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Profitable Products */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5" />
|
||||
Most Profitable Products
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Products generating the highest total profit (last {timeRange} days)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.topProfitableProducts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Calculator className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-muted-foreground">No profitable products data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.topProfitableProducts.map((product, index) => {
|
||||
const profitPositive = product.totalProfit >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={product.productId}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-semibold text-sm">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{product.productName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{product.totalQuantitySold} units sold
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={`font-medium ${profitPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{maskValue(formatGBP(product.totalProfit))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{maskValue(`${product.profitMargin.toFixed(1)}%`)} margin
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
lib/services/profit-analytics-service.ts
Normal file
48
lib/services/profit-analytics-service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { apiRequest } from '../api';
|
||||
|
||||
export interface ProfitOverview {
|
||||
period: string;
|
||||
summary: {
|
||||
totalRevenue: number;
|
||||
totalCost: number;
|
||||
totalProfit: number;
|
||||
overallProfitMargin: number;
|
||||
averageProfitPerUnit: number;
|
||||
costDataCoverage: number;
|
||||
totalProductsSold: number;
|
||||
productsWithCostData: number;
|
||||
};
|
||||
topProfitableProducts: Array<{
|
||||
productId: string;
|
||||
productName: string;
|
||||
totalQuantitySold: number;
|
||||
totalRevenue: number;
|
||||
totalCost: number;
|
||||
totalProfit: number;
|
||||
averageProfit: number;
|
||||
profitMargin: number;
|
||||
}>;
|
||||
hasCostData: boolean;
|
||||
}
|
||||
|
||||
export interface ProfitTrend {
|
||||
_id: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
};
|
||||
revenue: number;
|
||||
cost: number;
|
||||
profit: number;
|
||||
orders: number;
|
||||
itemsWithCostData: number;
|
||||
profitMargin: number;
|
||||
}
|
||||
|
||||
export const getProfitOverview = async (period: string = '30'): Promise<ProfitOverview> => {
|
||||
return apiRequest(`/analytics/profit-overview?period=${period}`);
|
||||
};
|
||||
|
||||
export const getProfitTrends = async (period: string = '30'): Promise<ProfitTrend[]> => {
|
||||
return apiRequest(`/analytics/profit-trends?period=${period}`);
|
||||
};
|
||||
Reference in New Issue
Block a user