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.
271 lines
9.2 KiB
TypeScript
271 lines
9.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|