Add year selection to admin analytics and split product table

AdminAnalytics now supports selecting historical years and updates available years dynamically from the API. The product table is refactored to display enabled and disabled products in separate tables, improving clarity. Minor formatting and code style improvements are also included.
This commit is contained in:
g
2026-01-05 21:55:55 +00:00
parent 74f3a2c5ae
commit c704ceed1d
5 changed files with 726 additions and 329 deletions

View File

@@ -1,18 +1,58 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { AlertCircle, BarChart, RefreshCw, Users, ShoppingCart,
TrendingUp, TrendingDown, DollarSign, Package, Trophy } from "lucide-react";
import {
AlertCircle,
BarChart,
RefreshCw,
Users,
ShoppingCart,
TrendingUp,
TrendingDown,
DollarSign,
Package,
Trophy,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { fetchClient } from "@/lib/api-client";
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, ComposedChart } from 'recharts';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
ComposedChart,
} from "recharts";
import { formatGBP } from "@/utils/format";
interface AnalyticsData {
meta?: {
year: number;
currentYear: number;
availableYears: number[];
range: string;
isCurrentYear: boolean;
};
vendors?: {
total?: number;
newToday?: number;
@@ -41,7 +81,12 @@ interface AnalyticsData {
total?: number;
today?: number;
thisWeek?: number;
dailyRevenue?: { date: string; amount: number; orders?: number; avgOrderValue?: number }[];
dailyRevenue?: {
date: string;
amount: number;
orders?: number;
avgOrderValue?: number;
}[];
};
engagement?: {
totalChats?: number;
@@ -64,20 +109,45 @@ interface AnalyticsData {
}
export default function AdminAnalytics() {
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
null,
);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState("7days");
const [selectedYear, setSelectedYear] = useState<number>(
new Date().getFullYear(),
);
const [availableYears, setAvailableYears] = useState<number[]>([
new Date().getFullYear(),
]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const currentYear = new Date().getFullYear();
const isViewingCurrentYear = selectedYear === currentYear;
const fetchAnalyticsData = async () => {
try {
setLoading(true);
setErrorMessage(null);
const data = await fetchClient<AnalyticsData>(`/admin/analytics?range=${dateRange}`);
// Build query params - include year if not current year
const params = new URLSearchParams();
params.set("range", dateRange);
if (selectedYear !== currentYear) {
params.set("year", selectedYear.toString());
}
const data = await fetchClient<AnalyticsData>(
`/admin/analytics?${params.toString()}`,
);
setAnalyticsData(data);
// Update available years from response if provided
if (data.meta?.availableYears && data.meta.availableYears.length > 0) {
setAvailableYears(data.meta.availableYears);
}
} catch (error) {
console.error("Error fetching analytics data:", error);
setErrorMessage("Failed to load analytics data. Please try again.");
@@ -89,7 +159,7 @@ export default function AdminAnalytics() {
useEffect(() => {
fetchAnalyticsData();
}, [dateRange]);
}, [dateRange, selectedYear]);
const handleRefresh = () => {
setRefreshing(true);
@@ -105,64 +175,97 @@ export default function AdminAnalytics() {
}
// Helper to transform data for recharts
const transformChartData = (data: Array<{ date: string; [key: string]: any }>, valueKey: string = "count") => {
const transformChartData = (
data: Array<{ date: string; [key: string]: any }>,
valueKey: string = "count",
) => {
if (!data || data.length === 0) return [];
return data.map(item => {
return data.map((item) => {
const dateStr = item.date;
// Parse YYYY-MM-DD format
const parts = dateStr.split('-');
const date = parts.length === 3
? new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
const parts = dateStr.split("-");
const date =
parts.length === 3
? new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
)
: new Date(dateStr);
// Format with day of week: "Mon, Nov 21"
const dayOfWeek = date.toLocaleDateString('en-GB', { weekday: 'short' });
const monthDay = date.toLocaleDateString('en-GB', { month: 'short', day: 'numeric' });
const dayOfWeek = date.toLocaleDateString("en-GB", { weekday: "short" });
const monthDay = date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
return {
date: dateStr,
formattedDate: `${dayOfWeek}, ${monthDay}`,
value: Number(item[valueKey]) || 0,
[valueKey]: Number(item[valueKey]) || 0
[valueKey]: Number(item[valueKey]) || 0,
};
});
};
// Helper to combine orders and revenue data for dual-axis chart
const combineOrdersAndRevenue = (orders: Array<{ date: string; count: number }>, revenue: Array<{ date: string; amount: number; orders?: number; avgOrderValue?: number }>) => {
const combineOrdersAndRevenue = (
orders: Array<{ date: string; count: number }>,
revenue: Array<{
date: string;
amount: number;
orders?: number;
avgOrderValue?: number;
}>,
) => {
if (!orders || orders.length === 0) return [];
// Create a map of revenue and AOV by date for quick lookup
const revenueMap = new Map<string, { amount: number; avgOrderValue: number }>();
const revenueMap = new Map<
string,
{ amount: number; avgOrderValue: number }
>();
if (revenue && revenue.length > 0) {
revenue.forEach(r => {
revenue.forEach((r) => {
revenueMap.set(r.date, {
amount: r.amount || 0,
avgOrderValue: r.avgOrderValue || 0
avgOrderValue: r.avgOrderValue || 0,
});
});
}
return orders.map(order => {
return orders.map((order) => {
const dateStr = order.date;
const parts = dateStr.split('-');
const date = parts.length === 3
? new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
const parts = dateStr.split("-");
const date =
parts.length === 3
? new Date(
parseInt(parts[0]),
parseInt(parts[1]) - 1,
parseInt(parts[2]),
)
: new Date(dateStr);
// Format with day of week: "Mon, Nov 21"
const dayOfWeek = date.toLocaleDateString('en-GB', { weekday: 'short' });
const monthDay = date.toLocaleDateString('en-GB', { month: 'short', day: 'numeric' });
const dayOfWeek = date.toLocaleDateString("en-GB", { weekday: "short" });
const monthDay = date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
const revenueData = revenueMap.get(dateStr) || { amount: 0, avgOrderValue: 0 };
const revenueData = revenueMap.get(dateStr) || {
amount: 0,
avgOrderValue: 0,
};
return {
date: dateStr,
formattedDate: `${dayOfWeek}, ${monthDay}`,
orders: order.count || 0,
revenue: revenueData.amount,
avgOrderValue: revenueData.avgOrderValue
avgOrderValue: revenueData.avgOrderValue,
};
});
};
@@ -172,34 +275,47 @@ export default function AdminAnalytics() {
if (active && payload && payload.length) {
const data = payload[0].payload;
const dataKey = payload[0].dataKey;
const isDualAxis = data.orders !== undefined && data.revenue !== undefined;
const isDualAxis =
data.orders !== undefined && data.revenue !== undefined;
// Determine if this is a currency amount or a count
// transformChartData creates both 'value' and the original key (count/amount)
// So we check the original key to determine the type
const isAmount = dataKey === 'amount' || dataKey === 'revenue' ||
(dataKey === 'value' && data.amount !== undefined && data.count === undefined);
const isAmount =
dataKey === "amount" ||
dataKey === "revenue" ||
(dataKey === "value" &&
data.amount !== undefined &&
data.count === undefined);
return (
<div className="bg-background border border-border p-3 rounded-lg shadow-lg">
<p className="text-sm font-medium mb-2">{data.formattedDate || label}</p>
<p className="text-sm font-medium mb-2">
{data.formattedDate || label}
</p>
{isDualAxis ? (
<div className="space-y-1">
<p className="text-sm text-blue-600">
Orders: <span className="font-semibold">{data.orders}</span>
</p>
<p className="text-sm text-green-600">
Revenue: <span className="font-semibold">{formatGBP(data.revenue)}</span>
Revenue:{" "}
<span className="font-semibold">{formatGBP(data.revenue)}</span>
</p>
{data.avgOrderValue !== undefined && data.avgOrderValue > 0 && (
<p className="text-sm text-purple-600">
Avg Order Value: <span className="font-semibold">{formatGBP(data.avgOrderValue)}</span>
Avg Order Value:{" "}
<span className="font-semibold">
{formatGBP(data.avgOrderValue)}
</span>
</p>
)}
</div>
) : (
<p className="text-sm text-primary">
{isAmount ? formatGBP(data.value || data.amount || 0) : `${data.value || data.count || 0}`}
{isAmount
? formatGBP(data.value || data.amount || 0)
: `${data.value || data.count || 0}`}
</p>
)}
</div>
@@ -209,7 +325,13 @@ export default function AdminAnalytics() {
};
// Trend indicator component for metric cards
const TrendIndicator = ({ current, previous }: { current: number, previous: number }) => {
const TrendIndicator = ({
current,
previous,
}: {
current: number;
previous: number;
}) => {
if (!current || !previous) return null;
const percentChange = ((current - previous) / previous) * 100;
@@ -217,10 +339,14 @@ export default function AdminAnalytics() {
if (Math.abs(percentChange) < 0.1) return null;
return (
<div className={`flex items-center text-xs font-medium ${percentChange >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{percentChange >= 0 ?
<TrendingUp className="h-3 w-3 mr-1" /> :
<TrendingDown className="h-3 w-3 mr-1" />}
<div
className={`flex items-center text-xs font-medium ${percentChange >= 0 ? "text-green-500" : "text-red-500"}`}
>
{percentChange >= 0 ? (
<TrendingUp className="h-3 w-3 mr-1" />
) : (
<TrendingDown className="h-3 w-3 mr-1" />
)}
{Math.abs(percentChange).toFixed(1)}%
</div>
);
@@ -228,55 +354,70 @@ export default function AdminAnalytics() {
// Format currency
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
maximumFractionDigits: 0
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: "GBP",
maximumFractionDigits: 0,
}).format(value);
};
// Calculate best month from daily data (for YTD view)
const calculateBestMonth = () => {
if (dateRange !== 'ytd' || !analyticsData?.revenue?.dailyRevenue || analyticsData.revenue.dailyRevenue.length === 0) {
if (
dateRange !== "ytd" ||
!analyticsData?.revenue?.dailyRevenue ||
analyticsData.revenue.dailyRevenue.length === 0
) {
return null;
}
// Group daily revenue by month
const monthlyTotals = new Map<string, { revenue: number; orders: number }>();
const monthlyTotals = new Map<
string,
{ revenue: number; orders: number }
>();
analyticsData.revenue.dailyRevenue.forEach(day => {
analyticsData.revenue.dailyRevenue.forEach((day) => {
const date = new Date(day.date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const current = monthlyTotals.get(monthKey) || { revenue: 0, orders: 0 };
monthlyTotals.set(monthKey, {
revenue: current.revenue + (day.amount || 0),
orders: current.orders
orders: current.orders,
});
});
// Also group orders by month
if (analyticsData.orders?.dailyOrders) {
analyticsData.orders.dailyOrders.forEach(day => {
analyticsData.orders.dailyOrders.forEach((day) => {
const date = new Date(day.date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const current = monthlyTotals.get(monthKey) || { revenue: 0, orders: 0 };
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const current = monthlyTotals.get(monthKey) || {
revenue: 0,
orders: 0,
};
monthlyTotals.set(monthKey, {
revenue: current.revenue,
orders: current.orders + (day.count || 0)
orders: current.orders + (day.count || 0),
});
});
}
// Find the month with highest revenue
let bestMonth: { month: string; revenue: number; orders: number } | null = null;
let bestMonth: { month: string; revenue: number; orders: number } | null =
null;
monthlyTotals.forEach((totals, monthKey) => {
if (!bestMonth || totals.revenue > bestMonth.revenue) {
const [year, month] = monthKey.split('-');
const monthName = new Date(parseInt(year), parseInt(month) - 1, 1).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
const [year, month] = monthKey.split("-");
const monthName = new Date(
parseInt(year),
parseInt(month) - 1,
1,
).toLocaleDateString("en-GB", { month: "long", year: "numeric" });
bestMonth = {
month: monthName,
revenue: totals.revenue,
orders: totals.orders
orders: totals.orders,
};
}
});
@@ -298,24 +439,60 @@ export default function AdminAnalytics() {
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold">Dashboard Analytics</h2>
<p className="text-muted-foreground">Overview of your marketplace performance</p>
<h2 className="text-2xl font-bold">
Dashboard Analytics
{!isViewingCurrentYear && (
<span className="ml-2 text-lg font-normal text-muted-foreground">
({selectedYear})
</span>
)}
</h2>
<p className="text-muted-foreground">
{isViewingCurrentYear
? "Overview of your marketplace performance"
: `Historical data for ${selectedYear}`}
</p>
</div>
<div className="flex items-center gap-2">
{/* Year selector */}
<Select
value={dateRange}
value={selectedYear.toString()}
onValueChange={(value) => setSelectedYear(parseInt(value, 10))}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{availableYears.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Date range selector - only show options for current year */}
<Select
value={isViewingCurrentYear ? dateRange : "year"}
onValueChange={setDateRange}
disabled={!isViewingCurrentYear}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Last 7 days" />
</SelectTrigger>
<SelectContent>
{isViewingCurrentYear ? (
<>
<SelectItem value="24hours">Last 24 hours</SelectItem>
<SelectItem value="7days">Last 7 days</SelectItem>
<SelectItem value="30days">Last 30 days</SelectItem>
<SelectItem value="ytd">Year to Date</SelectItem>
<SelectItem value="year">Last Year</SelectItem>
<SelectItem value="year">Full Year</SelectItem>
</>
) : (
<SelectItem value="year">Full Year</SelectItem>
)}
</SelectContent>
</Select>
@@ -326,7 +503,7 @@ export default function AdminAnalytics() {
disabled={refreshing}
>
<RefreshCw
className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`}
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
/>
</Button>
@@ -335,7 +512,7 @@ export default function AdminAnalytics() {
size="sm"
onClick={() => setShowDebug(!showDebug)}
>
{showDebug ? 'Hide' : 'Show'} Debug
{showDebug ? "Hide" : "Show"} Debug
</Button>
</div>
</div>
@@ -351,12 +528,19 @@ export default function AdminAnalytics() {
<div>
<div className="font-semibold mb-2">Orders:</div>
<div className="pl-4 space-y-1">
<div>Total: {analyticsData?.orders?.total || 'N/A'}</div>
<div>Today: {analyticsData?.orders?.totalToday || 'N/A'}</div>
<div>Daily Orders Array Length: {analyticsData?.orders?.dailyOrders?.length || 0}</div>
<div>Total: {analyticsData?.orders?.total || "N/A"}</div>
<div>Today: {analyticsData?.orders?.totalToday || "N/A"}</div>
<div>
Daily Orders Array Length:{" "}
{analyticsData?.orders?.dailyOrders?.length || 0}
</div>
<div>First 3 Daily Orders:</div>
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
{JSON.stringify(analyticsData?.orders?.dailyOrders?.slice(0, 3), null, 2)}
{JSON.stringify(
analyticsData?.orders?.dailyOrders?.slice(0, 3),
null,
2,
)}
</pre>
</div>
</div>
@@ -364,12 +548,19 @@ export default function AdminAnalytics() {
<div>
<div className="font-semibold mb-2">Revenue:</div>
<div className="pl-4 space-y-1">
<div>Total: {analyticsData?.revenue?.total || 'N/A'}</div>
<div>Today: {analyticsData?.revenue?.today || 'N/A'}</div>
<div>Daily Revenue Array Length: {analyticsData?.revenue?.dailyRevenue?.length || 0}</div>
<div>Total: {analyticsData?.revenue?.total || "N/A"}</div>
<div>Today: {analyticsData?.revenue?.today || "N/A"}</div>
<div>
Daily Revenue Array Length:{" "}
{analyticsData?.revenue?.dailyRevenue?.length || 0}
</div>
<div>First 3 Daily Revenue:</div>
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
{JSON.stringify(analyticsData?.revenue?.dailyRevenue?.slice(0, 3), null, 2)}
{JSON.stringify(
analyticsData?.revenue?.dailyRevenue?.slice(0, 3),
null,
2,
)}
</pre>
</div>
</div>
@@ -377,17 +568,26 @@ export default function AdminAnalytics() {
<div>
<div className="font-semibold mb-2">Vendors:</div>
<div className="pl-4 space-y-1">
<div>Total: {analyticsData?.vendors?.total || 'N/A'}</div>
<div>Daily Growth Array Length: {analyticsData?.vendors?.dailyGrowth?.length || 0}</div>
<div>Total: {analyticsData?.vendors?.total || "N/A"}</div>
<div>
Daily Growth Array Length:{" "}
{analyticsData?.vendors?.dailyGrowth?.length || 0}
</div>
<div>First 3 Daily Growth:</div>
<pre className="pl-4 bg-muted p-2 rounded overflow-auto max-h-32">
{JSON.stringify(analyticsData?.vendors?.dailyGrowth?.slice(0, 3), null, 2)}
{JSON.stringify(
analyticsData?.vendors?.dailyGrowth?.slice(0, 3),
null,
2,
)}
</pre>
</div>
</div>
<details className="mt-4">
<summary className="font-semibold cursor-pointer">Full JSON Response</summary>
<summary className="font-semibold cursor-pointer">
Full JSON Response
</summary>
<pre className="mt-2 bg-muted p-4 rounded overflow-auto max-h-96 text-[10px]">
{JSON.stringify(analyticsData, null, 2)}
</pre>
@@ -407,14 +607,24 @@ export default function AdminAnalytics() {
<Trophy className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Best Month (YTD)</div>
<div className="text-xl font-bold text-green-600">{bestMonth.month}</div>
<div className="text-sm font-medium text-muted-foreground">
Best Month (YTD)
</div>
<div className="text-xl font-bold text-green-600">
{bestMonth.month}
</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-muted-foreground">Revenue</div>
<div className="text-xl font-bold">{formatCurrency(bestMonth.revenue)}</div>
<div className="text-xs text-muted-foreground mt-1">{bestMonth.orders.toLocaleString()} orders</div>
<div className="text-sm font-medium text-muted-foreground">
Revenue
</div>
<div className="text-xl font-bold">
{formatCurrency(bestMonth.revenue)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{bestMonth.orders.toLocaleString()} orders
</div>
</div>
</div>
</CardContent>
@@ -426,13 +636,15 @@ export default function AdminAnalytics() {
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
<CardTitle className="text-sm font-medium">
Total Orders
</CardTitle>
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData?.orders?.total?.toLocaleString() || '0'}
{analyticsData?.orders?.total?.toLocaleString() || "0"}
</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<span>Today: {analyticsData?.orders?.totalToday || 0}</span>
@@ -446,17 +658,26 @@ export default function AdminAnalytics() {
<div className="mt-3 h-12 flex items-center justify-center">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
) : analyticsData?.orders?.dailyOrders && analyticsData.orders.dailyOrders.length > 0 ? (
) : analyticsData?.orders?.dailyOrders &&
analyticsData.orders.dailyOrders.length > 0 ? (
<div className="mt-3 h-12">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={transformChartData(analyticsData.orders.dailyOrders, 'count')} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<RechartsBarChart
data={transformChartData(
analyticsData.orders.dailyOrders,
"count",
)}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Bar dataKey="value" fill="#3b82f6" radius={[2, 2, 0, 0]} />
<Tooltip content={<CustomTooltip />} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No chart data available</div>
<div className="mt-3 text-xs text-muted-foreground">
No chart data available
</div>
)}
</CardContent>
</Card>
@@ -465,7 +686,9 @@ export default function AdminAnalytics() {
<Card>
<CardHeader className="pb-2">
<div className="flex justify-between items-start">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<CardTitle className="text-sm font-medium">
Total Revenue
</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
@@ -474,7 +697,9 @@ export default function AdminAnalytics() {
{formatCurrency(analyticsData?.revenue?.total || 0)}
</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<span>Today: {formatCurrency(analyticsData?.revenue?.today || 0)}</span>
<span>
Today: {formatCurrency(analyticsData?.revenue?.today || 0)}
</span>
<TrendIndicator
current={analyticsData?.revenue?.today || 0}
previous={(analyticsData?.revenue?.total || 0) / 30} // Rough estimate
@@ -485,17 +710,26 @@ export default function AdminAnalytics() {
<div className="mt-3 h-12 flex items-center justify-center">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
) : analyticsData?.revenue?.dailyRevenue && analyticsData.revenue.dailyRevenue.length > 0 ? (
) : analyticsData?.revenue?.dailyRevenue &&
analyticsData.revenue.dailyRevenue.length > 0 ? (
<div className="mt-3 h-12">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={transformChartData(analyticsData.revenue.dailyRevenue, 'amount')} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<RechartsBarChart
data={transformChartData(
analyticsData.revenue.dailyRevenue,
"amount",
)}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Bar dataKey="value" fill="#10b981" radius={[2, 2, 0, 0]} />
<Tooltip content={<CustomTooltip />} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No chart data available</div>
<div className="mt-3 text-xs text-muted-foreground">
No chart data available
</div>
)}
</CardContent>
</Card>
@@ -510,11 +744,13 @@ export default function AdminAnalytics() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData?.vendors?.total?.toLocaleString() || '0'}
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<span>Active: {analyticsData?.vendors?.active || 0}</span>
<span className="ml-2">Stores: {analyticsData?.vendors?.activeStores || 0}</span>
<span className="ml-2">
Stores: {analyticsData?.vendors?.activeStores || 0}
</span>
</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<span>New Today: {analyticsData?.vendors?.newToday || 0}</span>
@@ -528,17 +764,26 @@ export default function AdminAnalytics() {
<div className="mt-3 h-12 flex items-center justify-center">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full"></div>
</div>
) : analyticsData?.vendors?.dailyGrowth && analyticsData.vendors.dailyGrowth.length > 0 ? (
) : analyticsData?.vendors?.dailyGrowth &&
analyticsData.vendors.dailyGrowth.length > 0 ? (
<div className="mt-3 h-12">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={transformChartData(analyticsData.vendors.dailyGrowth, 'count')} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<RechartsBarChart
data={transformChartData(
analyticsData.vendors.dailyGrowth,
"count",
)}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<Bar dataKey="value" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
<Tooltip content={<CustomTooltip />} />
</RechartsBarChart>
</ResponsiveContainer>
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No chart data available</div>
<div className="mt-3 text-xs text-muted-foreground">
No chart data available
</div>
)}
</CardContent>
</Card>
@@ -553,7 +798,7 @@ export default function AdminAnalytics() {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData?.products?.total?.toLocaleString() || '0'}
{analyticsData?.products?.total?.toLocaleString() || "0"}
</div>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<span>New This Week: {analyticsData?.products?.recent || 0}</span>
@@ -573,7 +818,8 @@ export default function AdminAnalytics() {
<CardHeader>
<CardTitle>Order Trends</CardTitle>
<CardDescription>
Daily order volume and revenue processed over the selected time period
Daily order volume and revenue processed over the selected time
period
</CardDescription>
</CardHeader>
<CardContent>
@@ -581,16 +827,19 @@ export default function AdminAnalytics() {
<div className="flex items-center justify-center h-80">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
<p className="text-sm text-muted-foreground">Loading chart data...</p>
<p className="text-sm text-muted-foreground">
Loading chart data...
</p>
</div>
</div>
) : analyticsData?.orders?.dailyOrders && analyticsData.orders.dailyOrders.length > 0 ? (
) : analyticsData?.orders?.dailyOrders &&
analyticsData.orders.dailyOrders.length > 0 ? (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={combineOrdersAndRevenue(
analyticsData.orders.dailyOrders,
analyticsData.revenue?.dailyRevenue || []
analyticsData.revenue?.dailyRevenue || [],
)}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
@@ -605,19 +854,52 @@ export default function AdminAnalytics() {
<YAxis
yAxisId="left"
tick={{ fontSize: 12 }}
label={{ value: 'Orders', angle: -90, position: 'insideLeft' }}
label={{
value: "Orders",
angle: -90,
position: "insideLeft",
}}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickFormatter={(value) => `£${(value / 1000).toFixed(0)}k`}
label={{ value: 'Revenue / AOV', angle: 90, position: 'insideRight' }}
tickFormatter={(value) =>
`£${(value / 1000).toFixed(0)}k`
}
label={{
value: "Revenue / AOV",
angle: 90,
position: "insideRight",
}}
/>
<Tooltip content={<CustomTooltip />} />
<Bar yAxisId="left" dataKey="orders" fill="#3b82f6" radius={[2, 2, 0, 0]} name="Orders" />
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2} dot={{ fill: '#10b981', r: 4 }} name="Revenue" />
<Line yAxisId="right" type="monotone" dataKey="avgOrderValue" stroke="#a855f7" strokeWidth={2} strokeDasharray="5 5" dot={{ fill: '#a855f7', r: 3 }} name="Avg Order Value" />
<Bar
yAxisId="left"
dataKey="orders"
fill="#3b82f6"
radius={[2, 2, 0, 0]}
name="Orders"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="revenue"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: "#10b981", r: 4 }}
name="Revenue"
/>
<Line
yAxisId="right"
type="monotone"
dataKey="avgOrderValue"
stroke="#a855f7"
strokeWidth={2}
strokeDasharray="5 5"
dot={{ fill: "#a855f7", r: 3 }}
name="Avg Order Value"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
@@ -628,20 +910,30 @@ export default function AdminAnalytics() {
)}
{/* Calculate totals for the selected period */}
{analyticsData?.orders?.dailyOrders && analyticsData?.revenue?.dailyRevenue && (
{analyticsData?.orders?.dailyOrders &&
analyticsData?.revenue?.dailyRevenue && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">Total Revenue</div>
<div className="text-sm font-medium mb-1">
Total Revenue
</div>
<div className="text-2xl font-bold text-green-600">
{formatCurrency(
analyticsData.revenue.dailyRevenue.reduce((sum, day) => sum + (day.amount || 0), 0)
analyticsData.revenue.dailyRevenue.reduce(
(sum, day) => sum + (day.amount || 0),
0,
),
)}
</div>
</div>
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">Total Orders</div>
<div className="text-sm font-medium mb-1">
Total Orders
</div>
<div className="text-2xl font-bold text-blue-600">
{analyticsData.orders.dailyOrders.reduce((sum, day) => sum + (day.count || 0), 0).toLocaleString()}
{analyticsData.orders.dailyOrders
.reduce((sum, day) => sum + (day.count || 0), 0)
.toLocaleString()}
</div>
</div>
</div>
@@ -663,13 +955,22 @@ export default function AdminAnalytics() {
<div className="flex items-center justify-center h-80">
<div className="flex flex-col items-center gap-2">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"></div>
<p className="text-sm text-muted-foreground">Loading chart data...</p>
<p className="text-sm text-muted-foreground">
Loading chart data...
</p>
</div>
</div>
) : analyticsData?.vendors?.dailyGrowth && analyticsData.vendors.dailyGrowth.length > 0 ? (
) : analyticsData?.vendors?.dailyGrowth &&
analyticsData.vendors.dailyGrowth.length > 0 ? (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={transformChartData(analyticsData.vendors.dailyGrowth, 'count')} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<RechartsBarChart
data={transformChartData(
analyticsData.vendors.dailyGrowth,
"count",
)}
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="formattedDate"
@@ -680,7 +981,11 @@ export default function AdminAnalytics() {
/>
<YAxis tick={{ fontSize: 12 }} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="value" fill="#8b5cf6" radius={[2, 2, 0, 0]} />
<Bar
dataKey="value"
fill="#8b5cf6"
radius={[2, 2, 0, 0]}
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
@@ -693,40 +998,62 @@ export default function AdminAnalytics() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">Total Vendors</div>
<div className="text-2xl font-bold">{analyticsData?.vendors?.total?.toLocaleString() || '0'}</div>
<div className="text-2xl font-bold">
{analyticsData?.vendors?.total?.toLocaleString() || "0"}
</div>
</div>
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">Active Vendors</div>
<div className="text-2xl font-bold">{analyticsData?.vendors?.active?.toLocaleString() || '0'}</div>
<div className="text-2xl font-bold">
{analyticsData?.vendors?.active?.toLocaleString() || "0"}
</div>
</div>
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">Active Stores</div>
<div className="text-2xl font-bold">{analyticsData?.vendors?.activeStores?.toLocaleString() || '0'}</div>
<div className="text-2xl font-bold">
{analyticsData?.vendors?.activeStores?.toLocaleString() ||
"0"}
</div>
</div>
<div className="bg-muted/50 p-4 rounded-lg">
<div className="text-sm font-medium mb-1">New This Week</div>
<div className="text-2xl font-bold">{analyticsData?.vendors?.newThisWeek?.toLocaleString() || '0'}</div>
<div className="text-2xl font-bold">
{analyticsData?.vendors?.newThisWeek?.toLocaleString() ||
"0"}
</div>
</div>
</div>
{/* Top Vendors by Revenue */}
{analyticsData?.vendors?.topVendors && analyticsData.vendors.topVendors.length > 0 && (
{analyticsData?.vendors?.topVendors &&
analyticsData.vendors.topVendors.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-semibold mb-4">Top Vendors by Revenue</h3>
<h3 className="text-lg font-semibold mb-4">
Top Vendors by Revenue
</h3>
<div className="space-y-2">
{analyticsData.vendors.topVendors.map((vendor, index) => (
<div key={vendor.vendorId} className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
<div
key={vendor.vendorId}
className="flex items-center justify-between p-3 bg-muted/30 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>
<div className="font-medium">{vendor.vendorName}</div>
<div className="text-xs text-muted-foreground">{vendor.orderCount} orders</div>
<div className="font-medium">
{vendor.vendorName}
</div>
<div className="text-xs text-muted-foreground">
{vendor.orderCount} orders
</div>
</div>
</div>
<div className="text-right">
<div className="font-semibold text-green-600">{formatCurrency(vendor.totalRevenue)}</div>
<div className="font-semibold text-green-600">
{formatCurrency(vendor.totalRevenue)}
</div>
</div>
</div>
))}

View File

@@ -32,3 +32,4 @@ export function NotificationProviderWrapper({ children }: NotificationProviderWr
return <>{children}</>;
}

View File

@@ -1,5 +1,20 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Edit, Trash, AlertTriangle, CheckCircle, AlertCircle, Calculator, Copy } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Edit,
Trash,
AlertTriangle,
CheckCircle,
AlertCircle,
Calculator,
Copy,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Product } from "@/models/products";
import { Badge } from "@/components/ui/badge";
@@ -24,79 +39,73 @@ const ProductTable = ({
onDelete,
onToggleEnabled,
onProfitAnalysis,
getCategoryNameById
getCategoryNameById,
}: ProductTableProps) => {
// Separate enabled and disabled products
const enabledProducts = products.filter((p) => p.enabled !== false);
const disabledProducts = products.filter((p) => p.enabled === false);
const sortedProducts = [...products].sort((a, b) => {
const sortByCategory = (productList: Product[]) => {
return [...productList].sort((a, b) => {
const categoryNameA = getCategoryNameById(a.category);
const categoryNameB = getCategoryNameById(b.category);
return categoryNameA.localeCompare(categoryNameB);
});
};
const sortedEnabledProducts = sortByCategory(enabledProducts);
const sortedDisabledProducts = sortByCategory(disabledProducts);
const getStockIcon = (product: Product) => {
if (!product.stockTracking) return null;
if (product.stockStatus === 'out_of_stock') {
if (product.stockStatus === "out_of_stock") {
return <AlertTriangle className="h-4 w-4 text-red-500" />;
} else if (product.stockStatus === 'low_stock') {
} else if (product.stockStatus === "low_stock") {
return <AlertCircle className="h-4 w-4 text-amber-500" />;
} else {
return <CheckCircle className="h-4 w-4 text-green-500" />;
}
};
return (
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
<Table className="relative">
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">Product</TableHead>
<TableHead className="hidden sm:table-cell text-center">Category</TableHead>
<TableHead className="hidden md:table-cell text-center">Unit</TableHead>
<TableHead className="text-center">Stock</TableHead>
<TableHead className="hidden lg:table-cell text-center">Enabled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 1 }).map((_, index) => (
<TableRow key={index}>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
</TableRow>
))
) : sortedProducts.length > 0 ? (
sortedProducts.map((product) => (
<TableRow key={product._id} className="transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70">
const renderProductRow = (product: Product, isDisabled: boolean = false) => (
<TableRow
key={product._id}
className={`transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70 ${isDisabled ? "opacity-60" : ""}`}
>
<TableCell>
<div className="font-medium truncate max-w-[180px]">{product.name}</div>
<div className="hidden sm:block text-sm text-muted-foreground mt-1">
{getCategoryNameById(product.category)}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell text-center">{getCategoryNameById(product.category)}</TableCell>
<TableCell className="hidden md:table-cell text-center">{product.unitType}</TableCell>
<TableCell className="hidden sm:table-cell text-center">
{getCategoryNameById(product.category)}
</TableCell>
<TableCell className="hidden md:table-cell text-center">
{product.unitType}
</TableCell>
<TableCell className="text-center">
{product.stockTracking ? (
<div className="flex items-center justify-center gap-1">
{getStockIcon(product)}
<span className="text-sm">
{product.currentStock !== undefined ? product.currentStock : 0} {product.unitType}
{product.currentStock !== undefined ? product.currentStock : 0}{" "}
{product.unitType}
</span>
</div>
) : (
<Badge variant="outline" className="text-xs">Not Tracked</Badge>
<Badge variant="outline" className="text-xs">
Not Tracked
</Badge>
)}
</TableCell>
<TableCell className="hidden lg:table-cell text-center">
<Switch
checked={product.enabled !== false}
onCheckedChange={(checked) => onToggleEnabled(product._id as string, checked)}
onCheckedChange={(checked) =>
onToggleEnabled(product._id as string, checked)
}
/>
</TableCell>
<TableCell className="text-right flex justify-end space-x-1">
@@ -104,7 +113,9 @@ const ProductTable = ({
<Button
variant="ghost"
size="sm"
onClick={() => onProfitAnalysis(product._id as string, product.name)}
onClick={() =>
onProfitAnalysis(product._id as string, product.name)
}
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
title="Profit Analysis"
>
@@ -122,7 +133,12 @@ const ProductTable = ({
<Copy className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onEdit(product)} title="Edit Product">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(product)}
title="Edit Product"
>
<Edit className="h-4 w-4" />
</Button>
<Button
@@ -136,17 +152,70 @@ const ProductTable = ({
</Button>
</TableCell>
</TableRow>
);
const renderTableHeader = () => (
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
<TableRow className="hover:bg-transparent">
<TableHead className="w-[200px]">Product</TableHead>
<TableHead className="hidden sm:table-cell text-center">
Category
</TableHead>
<TableHead className="hidden md:table-cell text-center">Unit</TableHead>
<TableHead className="text-center">Stock</TableHead>
<TableHead className="hidden lg:table-cell text-center">
Enabled
</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
);
return (
<div className="space-y-6">
{/* Enabled Products Table */}
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
<Table className="relative">
{renderTableHeader()}
<TableBody>
{loading ? (
Array.from({ length: 1 }).map((_, index) => (
<TableRow key={index}>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
<TableCell>Loading...</TableCell>
</TableRow>
))
) : sortedEnabledProducts.length > 0 ? (
sortedEnabledProducts.map((product) => renderProductRow(product))
) : (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
No products found.
No enabled products found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Disabled Products Section */}
{!loading && disabledProducts.length > 0 && (
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden bg-gray-50/30 dark:bg-zinc-900/30">
<Table className="relative">
{renderTableHeader()}
<TableBody>
{sortedDisabledProducts.map((product) =>
renderProductRow(product, true),
)}
</TableBody>
</Table>
</div>
)}
</div>
);
};

View File

@@ -2,14 +2,14 @@ export interface Product {
_id?: string;
name: string;
description: string;
unitType: 'pcs' | 'gr' | 'kg' | 'ml' | 'oz' | 'lb';
unitType: "pcs" | "gr" | "kg" | "ml" | "oz" | "lb";
category: string;
enabled?: boolean;
// Stock management fields
stockTracking?: boolean;
currentStock?: number;
lowStockThreshold?: number;
stockStatus?: 'in_stock' | 'low_stock' | 'out_of_stock';
stockStatus?: "in_stock" | "low_stock" | "out_of_stock";
pricing: Array<{
minQuantity: number;
pricePerUnit: number;

View File

@@ -1,4 +1,4 @@
{
"commitHash": "66e9543",
"buildTime": "2025-12-31T07:04:51.067Z"
"commitHash": "74f3a2c",
"buildTime": "2026-01-05T21:55:11.573Z"
}