From f3fb067da716f15d144d1b6c0e4715caa488f891 Mon Sep 17 00:00:00 2001
From: NotII <46204250+NotII@users.noreply.github.com>
Date: Tue, 26 Aug 2025 21:03:05 +0100
Subject: [PATCH] Add profit analytics chart and service
Introduces a new ProfitAnalyticsChart component to display profit-related metrics, including total revenue, cost, profit, and top profitable products. Updates the AnalyticsDashboard to include a Profit tab and adds a profit-analytics-service for fetching profit data from the backend.
---
components/analytics/AnalyticsDashboard.tsx | 21 +-
components/analytics/ProfitAnalyticsChart.tsx | 270 ++++++++++++++++++
lib/services/profit-analytics-service.ts | 48 ++++
3 files changed, 336 insertions(+), 3 deletions(-)
create mode 100644 components/analytics/ProfitAnalyticsChart.tsx
create mode 100644 lib/services/profit-analytics-service.ts
diff --git a/components/analytics/AnalyticsDashboard.tsx b/components/analytics/AnalyticsDashboard.tsx
index edcc9c2..428c9de 100644
--- a/components/analytics/AnalyticsDashboard.tsx
+++ b/components/analytics/AnalyticsDashboard.tsx
@@ -17,7 +17,8 @@ import {
Activity,
RefreshCw,
Eye,
- EyeOff
+ EyeOff,
+ Calculator
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import MetricsCard from "./MetricsCard";
@@ -44,6 +45,10 @@ const OrderAnalyticsChart = dynamic(() => import('./OrderAnalyticsChart'), {
loading: () =>
});
+const ProfitAnalyticsChart = dynamic(() => import('./ProfitAnalyticsChart'), {
+ loading: () =>
+});
+
// Chart loading skeleton
function ChartSkeleton() {
return (
@@ -249,7 +254,7 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
Time Period
- Revenue and Orders tabs use time filtering. Products and Customers show all-time data.
+ Revenue, Profit, and Orders tabs use time filtering. Products and Customers show all-time data.
@@ -267,11 +272,15 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
{/* Analytics Tabs */}
-
+
Revenue
+
+
+ Profit
+
Products
@@ -292,6 +301,12 @@ export default function AnalyticsDashboard({ initialData }: AnalyticsDashboardPr
+
+ }>
+
+
+
+
}>
diff --git a/components/analytics/ProfitAnalyticsChart.tsx b/components/analytics/ProfitAnalyticsChart.tsx
new file mode 100644
index 0000000..18cec61
--- /dev/null
+++ b/components/analytics/ProfitAnalyticsChart.tsx
@@ -0,0 +1,270 @@
+"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 } from "@/lib/services/profit-analytics-service";
+import { TableSkeleton } from './SkeletonLoaders';
+
+interface ProfitAnalyticsChartProps {
+ timeRange: string;
+ hideNumbers?: boolean;
+}
+
+export default function ProfitAnalyticsChart({ timeRange, hideNumbers = false }: ProfitAnalyticsChartProps) {
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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);
+ const response = await getProfitOverview(timeRange);
+ 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, toast]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !data) {
+ return (
+
+
+
+
+ Profit Analytics
+
+
+
+
+
+
Failed to load profit data
+
+
+
+ );
+ }
+
+ if (!data.hasCostData) {
+ return (
+
+
+
+
+ Profit Analytics
+
+
+ Track your actual profits based on sales and cost data
+
+
+
+
+
+
+ No cost data available
+ Add cost prices to your products to see profit analytics. Go to Products → Edit → Cost & Profit Tracking section.
+
+
+
+
+ );
+ }
+
+ const profitDirection = data.summary.totalProfit >= 0;
+
+ return (
+
+ {/* Summary Cards */}
+
+
+
+ Total Revenue
+
+
+
+ {maskValue(formatGBP(data.summary.totalRevenue))}
+
+
+ From {data.summary.totalProductsSold} items sold
+
+
+
+
+
+
+ Total Cost
+
+
+
+ {maskValue(formatGBP(data.summary.totalCost))}
+
+
+ From {data.summary.productsWithCostData} tracked items
+
+
+
+
+
+
+ Total Profit
+
+
+
+ {profitDirection ? (
+
+ ) : (
+
+ )}
+ {maskValue(formatGBP(data.summary.totalProfit))}
+
+
+ {maskValue(`${data.summary.overallProfitMargin.toFixed(1)}%`)} margin
+
+
+
+
+
+
+ Avg Profit/Unit
+
+
+
+ {maskValue(formatGBP(data.summary.averageProfitPerUnit))}
+
+
+ Per unit sold
+
+
+
+
+
+ {/* Cost Data Coverage */}
+
+
+
+
+ Cost Data Coverage
+
+
+ Percentage of sold items that have cost data for profit calculation
+
+
+
+
+
+ {hideNumbers ? "**%" : `${data.summary.costDataCoverage.toFixed(1)}%`}
+
+
+
+ {hideNumbers ? "** / **" : `${data.summary.productsWithCostData} / ${data.summary.totalProductsSold}`}
+
+
+
+
+
+ {/* Top Profitable Products */}
+
+
+
+
+ Most Profitable Products
+
+
+ Products generating the highest total profit (last {timeRange} days)
+
+
+
+ {data.topProfitableProducts.length === 0 ? (
+
+
+
No profitable products data available
+
+ ) : (
+
+ {data.topProfitableProducts.map((product, index) => {
+ const profitPositive = product.totalProfit >= 0;
+
+ return (
+
+
+
+ {index + 1}
+
+
+
{product.productName}
+
+ {product.totalQuantitySold} units sold
+
+
+
+
+
+
+ {maskValue(formatGBP(product.totalProfit))}
+
+
+ {maskValue(`${product.profitMargin.toFixed(1)}%`)} margin
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+ );
+}
diff --git a/lib/services/profit-analytics-service.ts b/lib/services/profit-analytics-service.ts
new file mode 100644
index 0000000..ae67545
--- /dev/null
+++ b/lib/services/profit-analytics-service.ts
@@ -0,0 +1,48 @@
+import { apiRequest } from '../api';
+
+export interface ProfitOverview {
+ period: string;
+ summary: {
+ totalRevenue: number;
+ totalCost: number;
+ totalProfit: number;
+ overallProfitMargin: number;
+ averageProfitPerUnit: number;
+ costDataCoverage: number;
+ totalProductsSold: number;
+ productsWithCostData: number;
+ };
+ topProfitableProducts: Array<{
+ productId: string;
+ productName: string;
+ totalQuantitySold: number;
+ totalRevenue: number;
+ totalCost: number;
+ totalProfit: number;
+ averageProfit: number;
+ profitMargin: number;
+ }>;
+ hasCostData: boolean;
+}
+
+export interface ProfitTrend {
+ _id: {
+ year: number;
+ month: number;
+ day: number;
+ };
+ revenue: number;
+ cost: number;
+ profit: number;
+ orders: number;
+ itemsWithCostData: number;
+ profitMargin: number;
+}
+
+export const getProfitOverview = async (period: string = '30'): Promise => {
+ return apiRequest(`/analytics/profit-overview?period=${period}`);
+};
+
+export const getProfitTrends = async (period: string = '30'): Promise => {
+ return apiRequest(`/analytics/profit-trends?period=${period}`);
+};