Introduces a date range picker to the profit analytics dashboard, allowing users to select custom date ranges for profit calculations. Updates ProfitAnalyticsChart and profit-analytics-service to handle both period and date range queries, improving flexibility and user experience.
361 lines
13 KiB
TypeScript
361 lines
13 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, 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 { 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
|
|
const response = await getProfitOverview(dateRange || timeRange || '30');
|
|
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"
|
|
>
|
|
<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>
|
|
);
|
|
}
|