Files
ember-market-frontend/components/analytics/ProfitAnalyticsChart.tsx
g fe01f31538
Some checks failed
Build Frontend / build (push) Failing after 7s
Refactor UI imports and update component paths
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
2026-01-13 05:02:13 +00:00

384 lines
14 KiB
TypeScript

"use client"
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
import { Badge } from "@/components/common/badge";
import { Alert, AlertDescription } from "@/components/common/alert";
import {
TrendingUp,
TrendingDown,
DollarSign,
PieChart,
Calculator,
Info,
AlertTriangle,
Package
} from "lucide-react";
import { useToast } from "@/lib/hooks/use-toast";
import { formatGBP } from "@/lib/utils/format";
import { getProfitOverview, type ProfitOverview, type DateRange } from "@/lib/services/profit-analytics-service";
import { Skeleton } from "@/components/common/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>
);
}