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:
NotII
2025-08-26 21:03:05 +01:00
parent be746664c5
commit f3fb067da7
3 changed files with 336 additions and 3 deletions

View File

@@ -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 />

View 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>
);
}

View 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}`);
};