Enhanced ProfitAnalyticsChart to display more robust skeleton loaders and improved summary card logic with fallbacks for missing data. Updated the chart to distinguish between tracked and total revenue, and improved cost data coverage display. In the profit analysis modal, profit margin tiers are now sorted by minimum quantity. Updated the ProfitOverview interface to include revenueFromTrackedProducts.
356 lines
12 KiB
TypeScript
356 lines
12 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 { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
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 (
|
|
<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>
|
|
<div className="space-y-6">
|
|
{/* Summary Cards Skeleton */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="pb-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-8 w-20 mb-2" />
|
|
<Skeleton className="h-3 w-16" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Coverage Card Skeleton */}
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-40" />
|
|
<Skeleton className="h-4 w-60" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-8 w-16" />
|
|
<div className="flex-1">
|
|
<Skeleton className="h-2 w-full rounded-full" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Products List Skeleton */}
|
|
<Card>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-48" />
|
|
<Skeleton className="h-4 w-64" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{[...Array(3)].map((_, i) => (
|
|
<div key={i} className="flex items-center justify-between p-4 border rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
<div>
|
|
<Skeleton className="h-4 w-32 mb-2" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<Skeleton className="h-4 w-20 mb-2" />
|
|
<Skeleton className="h-3 w-16" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
// Fallback for backwards compatibility
|
|
const revenueFromTracked = data.summary.revenueFromTrackedProducts || data.summary.totalRevenue || 0;
|
|
const totalRevenue = data.summary.totalRevenue || 0;
|
|
const totalCost = data.summary.totalCost || 0;
|
|
const totalProfit = data.summary.totalProfit || 0;
|
|
const productsWithCostData = data.summary.productsWithCostData || 0;
|
|
const totalProductsSold = data.summary.totalProductsSold || 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">Revenue (Tracked)</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{maskValue(formatGBP(revenueFromTracked))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
From {productsWithCostData} tracked items
|
|
</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Total revenue: {maskValue(formatGBP(totalRevenue))}
|
|
</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(totalCost))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
From {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(totalProfit))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{maskValue(`${(data.summary.overallProfitMargin || 0).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 || 0))}
|
|
</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 || 0).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 || 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Badge variant="secondary">
|
|
{hideNumbers ? "** / **" : `${productsWithCostData} / ${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>
|
|
);
|
|
}
|