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,
|
Activity,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff
|
EyeOff,
|
||||||
|
Calculator
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import MetricsCard from "./MetricsCard";
|
import MetricsCard from "./MetricsCard";
|
||||||
@@ -44,6 +45,10 @@ const OrderAnalyticsChart = dynamic(() => import('./OrderAnalyticsChart'), {
|
|||||||
loading: () => <ChartSkeleton />
|
loading: () => <ChartSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ProfitAnalyticsChart = dynamic(() => import('./ProfitAnalyticsChart'), {
|
||||||
|
loading: () => <ChartSkeleton />
|
||||||
|
});
|
||||||
|
|
||||||
// Chart loading skeleton
|
// Chart loading skeleton
|
||||||
function ChartSkeleton() {
|
function ChartSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -249,7 +254,7 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Time Period</h3>
|
<h3 className="text-lg font-semibold">Time Period</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
@@ -267,11 +272,15 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
|||||||
{/* Analytics Tabs */}
|
{/* Analytics Tabs */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Tabs defaultValue="revenue" 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">
|
<TabsTrigger value="revenue" className="flex items-center gap-2">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<TrendingUp className="h-4 w-4" />
|
||||||
Revenue
|
Revenue
|
||||||
</TabsTrigger>
|
</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">
|
<TabsTrigger value="products" className="flex items-center gap-2">
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Products
|
Products
|
||||||
@@ -292,6 +301,12 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="products" className="space-y-6">
|
||||||
<Suspense fallback={<ChartSkeleton />}>
|
<Suspense fallback={<ChartSkeleton />}>
|
||||||
<ProductPerformanceChart />
|
<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