Some checks failed
Build Frontend / build (push) Failing after 7s
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.
244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/common/card";
|
|
import { useToast } from "@/lib/hooks/use-toast";
|
|
import { Skeleton } from "@/components/common/skeleton";
|
|
import { TrendingUp, DollarSign } from "lucide-react";
|
|
import { getRevenueTrendsWithStore, type RevenueData } from "@/lib/services/analytics-service";
|
|
import { formatGBP } from "@/lib/utils/format";
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts';
|
|
import { ChartSkeleton } from './SkeletonLoaders';
|
|
|
|
interface RevenueChartProps {
|
|
timeRange: string;
|
|
hideNumbers?: boolean;
|
|
}
|
|
|
|
interface ChartDataPoint {
|
|
date: string;
|
|
revenue: number;
|
|
orders: number;
|
|
formattedDate: string;
|
|
}
|
|
|
|
export default function RevenueChart({ timeRange, hideNumbers = false }: RevenueChartProps) {
|
|
const [data, setData] = useState<RevenueData[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
const fetchRevenueData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await getRevenueTrendsWithStore(timeRange);
|
|
console.log('Revenue trends response:', response);
|
|
setData(Array.isArray(response) ? response : []);
|
|
} catch (err) {
|
|
console.error('Error fetching revenue data:', err);
|
|
setError('Failed to load revenue data');
|
|
setData([]);
|
|
toast({
|
|
title: "Error",
|
|
description: "Failed to load revenue trends data.",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchRevenueData();
|
|
}, [timeRange, toast]);
|
|
|
|
// Transform data for Recharts
|
|
const chartData: ChartDataPoint[] = data.map(item => {
|
|
// Use UTC to avoid timezone issues
|
|
const date = new Date(Date.UTC(item._id.year, item._id.month - 1, item._id.day));
|
|
return {
|
|
date: date.toISOString().split('T')[0], // YYYY-MM-DD format
|
|
revenue: item.revenue || 0,
|
|
orders: item.orders || 0,
|
|
formattedDate: date.toLocaleDateString('en-GB', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
timeZone: 'UTC'
|
|
})
|
|
};
|
|
});
|
|
|
|
// Calculate summary stats
|
|
const safeData = Array.isArray(data) ? data : [];
|
|
const totalRevenue = safeData.reduce((sum, item) => sum + (item.revenue || 0), 0);
|
|
const totalOrders = safeData.reduce((sum, item) => sum + (item.orders || 0), 0);
|
|
const averageRevenue = safeData.length > 0 ? totalRevenue / safeData.length : 0;
|
|
|
|
// Function to mask sensitive numbers
|
|
const maskValue = (value: string): string => {
|
|
if (!hideNumbers) return value;
|
|
|
|
// For currency values (£X.XX), show £***
|
|
if (value.includes('£')) {
|
|
return '£***';
|
|
}
|
|
|
|
// For regular numbers, replace with asterisks
|
|
return '***';
|
|
};
|
|
|
|
// Custom tooltip for the chart
|
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
if (active && payload && payload.length) {
|
|
const data = payload[0].payload;
|
|
return (
|
|
<div className="bg-background p-3 border border-border rounded-lg shadow-lg">
|
|
<p className="text-sm font-medium text-foreground">{data.formattedDate}</p>
|
|
<p className="text-sm text-blue-600 dark:text-blue-400">
|
|
Revenue: <span className="font-semibold">{hideNumbers ? '£***' : formatGBP(data.revenue)}</span>
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Orders: <span className="font-semibold">{hideNumbers ? '***' : data.orders}</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<ChartSkeleton
|
|
title="Revenue Trends"
|
|
description="Revenue performance over the selected time period"
|
|
icon={TrendingUp}
|
|
showStats={true}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Revenue Trends
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-8">
|
|
<DollarSign className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<p className="text-muted-foreground">Failed to load revenue data</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (chartData.length === 0) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Revenue Trends
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Revenue performance over the selected time period
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-center py-8">
|
|
<DollarSign className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
|
<p className="text-muted-foreground">No revenue data available for this period</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<TrendingUp className="h-5 w-5" />
|
|
Revenue Trends
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Revenue performance over the selected time period
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{/* Chart */}
|
|
<div className="h-64">
|
|
<ResponsiveContainer key={timeRange} width="100%" height="100%">
|
|
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
|
<defs>
|
|
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.8} />
|
|
<stop offset="95%" stopColor="#2563eb" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="hsl(var(--border))" />
|
|
<XAxis
|
|
dataKey="formattedDate"
|
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={60}
|
|
minTickGap={30}
|
|
/>
|
|
<YAxis
|
|
tick={{ fontSize: 12, fill: "hsl(var(--muted-foreground))" }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tickFormatter={(value) => hideNumbers ? '***' : `£${(value / 1000).toFixed(0)}k`}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: "transparent", stroke: "hsl(var(--muted-foreground))", strokeDasharray: "3 3" }} />
|
|
<Area
|
|
type="monotone"
|
|
dataKey="revenue"
|
|
stroke="#2563eb"
|
|
fillOpacity={1}
|
|
fill="url(#colorRevenue)"
|
|
strokeWidth={2}
|
|
activeDot={{ r: 4, strokeWidth: 0 }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Summary stats */}
|
|
<div className="grid grid-cols-3 gap-4 pt-4 border-t">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{maskValue(formatGBP(totalRevenue))}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Total Revenue</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-blue-600">
|
|
{maskValue(totalOrders.toString())}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Total Orders</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-purple-600">
|
|
{maskValue(formatGBP(averageRevenue))}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">Avg Daily Revenue</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|