Files
ember-market-frontend/components/analytics/ProfitAnalyticsChart.tsx
g a05787a091
All checks were successful
Build Frontend / build (push) Successful in 1m11s
Revamp analytics dashboard UI and charts
Enhanced the AnalyticsDashboard layout with a premium glassmorphism UI, improved toolbar, and reorganized tabs for better clarity. MetricsCard now features dynamic color coding and trend badges. PredictionsChart received scenario simulation UI upgrades, disabled future ranges based on available history, and improved chart tooltips and visuals. ProfitAnalyticsChart added error handling for product images and minor UI refinements. Updated globals.css with new premium utility classes and improved dark mode color variables.
2026-01-12 05:44:54 +00:00

382 lines
14 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,
Package
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { formatGBP } from "@/utils/format";
import { getProfitOverview, type ProfitOverview, type DateRange } from "@/lib/services/profit-analytics-service";
import { Skeleton } from "@/components/ui/skeleton";
interface ProfitAnalyticsChartProps {
timeRange?: string;
dateRange?: DateRange;
hideNumbers?: boolean;
}
export default function ProfitAnalyticsChart({ timeRange, dateRange, hideNumbers = false }: ProfitAnalyticsChartProps) {
const [data, setData] = useState<ProfitOverview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
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);
// Use dateRange if provided, otherwise fall back to timeRange, otherwise default to '30'
const periodOrRange = dateRange || (timeRange ? timeRange : '30');
const response = await getProfitOverview(periodOrRange);
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, dateRange, 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 sm:grid-cols-2 lg:grid-cols-3 xl: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 sm:grid-cols-2 lg:grid-cols-3 xl: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>
{dateRange
? `Products generating the highest total profit (${new Date(dateRange.from).toLocaleDateString()} - ${new Date(dateRange.to).toLocaleDateString()})`
: `Products generating the highest total profit (last ${timeRange || '30'} 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 transition-colors hover:bg-muted/30"
>
<div className="flex items-center gap-4">
<div className="relative flex-shrink-0">
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-background shadow-sm bg-muted flex items-center justify-center">
{product.image && !imageErrors[product.productId] ? (
<img
src={`/api/products/${product.productId}/image`}
alt={product.productName}
className="w-full h-full object-cover"
onError={() => {
setImageErrors(prev => ({ ...prev, [product.productId]: true }));
}}
/>
) : (
<div className="flex items-center justify-center w-full h-full bg-primary/10 text-primary font-bold text-lg">
{product.productName.charAt(0)}
</div>
)}
</div>
<div className="absolute -top-1 -left-1 w-5 h-5 bg-primary text-[10px] text-primary-foreground flex items-center justify-center rounded-full font-bold border-2 border-background shadow-sm">
{index + 1}
</div>
</div>
<div>
<p className="font-semibold">{product.productName}</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Package className="h-3 w-3" />
{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>
);
}