feat: Complete analytics implementation with all 7 pages, ROI calculation, conversion rate formatting, and chart improvements

This commit is contained in:
dwindown
2025-11-04 18:08:00 +07:00
parent 232059e928
commit 7c0d9639b6
6 changed files with 1124 additions and 120 deletions

View File

@@ -15,50 +15,50 @@ export interface AnalyticsParams {
export const AnalyticsApi = {
/**
* Dashboard Overview
* GET /woonoow/v1/analytics/overview
* GET /analytics/overview
*/
overview: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/overview', params),
api.get('/analytics/overview', params),
/**
* Revenue Analytics
* GET /woonoow/v1/analytics/revenue
* GET /analytics/revenue
*/
revenue: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/revenue', params),
api.get('/analytics/revenue', params),
/**
* Orders Analytics
* GET /woonoow/v1/analytics/orders
* GET /analytics/orders
*/
orders: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/orders', params),
api.get('/analytics/orders', params),
/**
* Products Analytics
* GET /woonoow/v1/analytics/products
* GET /analytics/products
*/
products: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/products', params),
api.get('/analytics/products', params),
/**
* Customers Analytics
* GET /woonoow/v1/analytics/customers
* GET /analytics/customers
*/
customers: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/customers', params),
api.get('/analytics/customers', params),
/**
* Coupons Analytics
* GET /woonoow/v1/analytics/coupons
* GET /analytics/coupons
*/
coupons: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/coupons', params),
api.get('/analytics/coupons', params),
/**
* Taxes Analytics
* GET /woonoow/v1/analytics/taxes
* GET /analytics/taxes
*/
taxes: (params?: AnalyticsParams) =>
api.get('/woonoow/v1/analytics/taxes', params),
api.get('/analytics/taxes', params),
};

View File

@@ -1,5 +1,5 @@
import React, { useState, useMemo, useRef } from 'react';
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { BarChart, Bar, LineChart, Line, AreaChart, Area, ComposedChart, PieChart, Pie, Cell, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { ShoppingCart, TrendingUp, Package, XCircle, DollarSign, CheckCircle, Clock } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
@@ -16,12 +16,14 @@ import { DUMMY_ORDERS_DATA, OrdersData } from './data/dummyOrders';
export default function OrdersAnalytics() {
const { period } = useDashboardPeriod();
const store = getStoreCurrency();
const [activeStatus, setActiveStatus] = useState('all');
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
const chartRef = useRef<any>(null);
// Fetch real data or use dummy data based on toggle
const { data, isLoading, error, refetch } = useOrdersAnalytics(DUMMY_ORDERS_DATA);
// Auto-select first status (completed) on load
const [activeStatus, setActiveStatus] = useState(data.by_status[0]?.status_label || 'All');
// Filter chart data by period
const chartData = useMemo(() => {
@@ -205,7 +207,13 @@ export default function OrdersAnalytics() {
description={__('Daily order count and status breakdown')}
>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<ComposedChart data={chartData}>
<defs>
<linearGradient id="totalOrdersGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
@@ -235,19 +243,39 @@ export default function OrdersAnalytics() {
}}
/>
<Legend />
<Line
{/* Total Orders as Area (background) */}
<Area
type="monotone"
dataKey="orders"
name={__('Total Orders')}
stroke="#3b82f6"
strokeWidth={2}
fill="url(#totalOrdersGradient)"
/>
{/* Individual statuses as Lines */}
<Line
type="monotone"
dataKey="completed"
name={__('Completed')}
stroke="#10b981"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="processing"
name={__('Processing')}
stroke="#60a5fa"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="pending"
name={__('Pending')}
stroke="#f59e0b"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
@@ -255,8 +283,9 @@ export default function OrdersAnalytics() {
name={__('Cancelled')}
stroke="#ef4444"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ComposedChart>
</ResponsiveContainer>
</ChartCard>

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { AreaChart, Area, BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { AreaChart, Area, BarChart, Bar, LineChart, Line, ComposedChart, PieChart, Pie, Cell, Sector, Label, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { TrendingUp, TrendingDown, ShoppingCart, DollarSign, Package, Users, AlertTriangle, ArrowUpRight } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
@@ -125,11 +125,17 @@ const DUMMY_DATA = {
// Metric card component
function MetricCard({ title, value, change, icon: Icon, format = 'number', period }: any) {
const safeValue = value ?? 0;
const formattedValue = format === 'money' ? formatMoney(safeValue) : format === 'percent' ? `${safeValue.toFixed(2)}%` : safeValue.toLocaleString();
// Don't show comparison for "All Time"
const showComparison = change !== undefined && period !== 'all';
const isPositive = change >= 0;
const formattedValue = format === 'money' ? formatMoney(value) : format === 'percent' ? `${value}%` : value.toLocaleString();
// Period comparison text
const periodText = period === '7' ? __('vs previous 7 days') : period === '14' ? __('vs previous 14 days') : __('vs previous 30 days');
const periodText = period === '7' ? __('vs previous 7 days') :
period === '14' ? __('vs previous 14 days') :
period === '30' ? __('vs previous 30 days') : '';
return (
<div className="rounded-lg border bg-card p-6">
@@ -139,10 +145,12 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
</div>
<div className="space-y-1">
<div className="text-2xl font-bold">{formattedValue}</div>
<div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
{Math.abs(change).toFixed(1)}% {periodText}
</div>
{showComparison && (
<div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
{isPositive ? <TrendingUp className="w-3 h-3 mr-1" /> : <TrendingDown className="w-3 h-3 mr-1" />}
{Math.abs(change).toFixed(1)}% {periodText}
</div>
)}
</div>
</div>
);
@@ -152,13 +160,32 @@ function MetricCard({ title, value, change, icon: Icon, format = 'number', perio
export default function Dashboard() {
const { period } = useDashboardPeriod();
const store = getStoreCurrency();
const [activeStatus, setActiveStatus] = useState('all');
const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined);
const [chartMetric, setChartMetric] = useState<'both' | 'revenue' | 'orders'>('both');
const chartRef = useRef<any>(null);
// Fetch real data or use dummy data based on toggle
const { data, isLoading, error, refetch } = useOverviewAnalytics(DUMMY_DATA);
// Sort order status by importance (completed first)
const sortedOrderStatus = useMemo(() => {
const statusOrder = ['completed', 'processing', 'pending', 'on-hold', 'cancelled', 'refunded', 'failed'];
const orderStatus = data.orderStatus || data.orderStatusDistribution || [];
if (!Array.isArray(orderStatus)) return [];
return [...orderStatus].sort((a, b) => {
const statusA = (a.status || a.name || '').toLowerCase();
const statusB = (b.status || b.name || '').toLowerCase();
const posA = statusOrder.indexOf(statusA);
const posB = statusOrder.indexOf(statusB);
return (posA === -1 ? 999 : posA) - (posB === -1 ? 999 : posB);
});
}, [data.orderStatus, data.orderStatusDistribution]);
// Auto-select first status (completed) on load
const [activeStatus, setActiveStatus] = useState(
sortedOrderStatus[0]?.status || sortedOrderStatus[0]?.name || 'All'
);
// Filter chart data based on period
const chartData = useMemo(() => {
@@ -349,11 +376,11 @@ export default function Dashboard() {
<ResponsiveContainer width="100%" height={300}>
{chartMetric === 'both' ? (
<AreaChart data={chartData}>
<ComposedChart data={chartData}>
<defs>
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
@@ -372,7 +399,7 @@ export default function Dashboard() {
<p className="text-sm font-bold">{label}</p>
{payload.map((entry: any, index: number) => (
<p key={index} style={{ color: entry.color }}>
<span className="font-bold">{entry.name}:</span> {entry.name === __('Revenue') ? formatMoney(Number(entry.value)) : entry.value.toLocaleString()}
<span className="font-bold">{entry.name}:</span> {entry.name === __('Revenue') ? formatMoney(Number(entry.value || 0)) : (entry.value ?? 0).toLocaleString()}
</p>
))}
</div>
@@ -382,9 +409,9 @@ export default function Dashboard() {
}}
/>
<Legend />
<Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} />
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2} name={__('Orders')} />
</AreaChart>
<Area yAxisId="left" type="monotone" dataKey="revenue" stroke="#3b82f6" strokeWidth={2.5} fillOpacity={1} fill="url(#colorRevenue)" name={__('Revenue')} />
<Line yAxisId="right" type="monotone" dataKey="orders" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: '#10b981' }} activeDot={{ r: 6 }} name={__('Orders')} />
</ComposedChart>
) : chartMetric === 'revenue' ? (
<AreaChart data={chartData}>
<defs>
@@ -433,7 +460,7 @@ export default function Dashboard() {
<p className="text-sm font-bold">{label}</p>
{payload.map((entry: any, index: number) => (
<p key={index} style={{ color: entry.color }}>
<span className="font-bold">{entry.name}:</span> {entry.value.toLocaleString()}
<span className="font-bold">{entry.name}:</span> {(entry.value ?? 0).toLocaleString()}
</p>
))}
</div>
@@ -462,14 +489,17 @@ export default function Dashboard() {
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{DUMMY_DATA.orderStatusDistribution.map((status) => (
<SelectItem key={status.name} value={status.name}>
<span className="flex items-center gap-2 text-xs">
<span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} />
{status.name}
</span>
</SelectItem>
))}
{sortedOrderStatus.map((status, idx) => {
const statusValue = status.status || status.name || `status-${idx}`;
return (
<SelectItem key={statusValue} value={statusValue}>
<span className="flex items-center gap-2 text-xs">
<span className="flex h-3 w-3 shrink-0 rounded" style={{ backgroundColor: status.color }} />
{statusValue}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -480,9 +510,9 @@ export default function Dashboard() {
onMouseLeave={handleChartMouseLeave}
>
<Pie
data={DUMMY_DATA.orderStatusDistribution}
dataKey="value"
nameKey="name"
data={sortedOrderStatus}
dataKey={(entry) => entry.count || entry.value || 0}
nameKey={(entry) => entry.status || entry.name || 'Unknown'}
cx="50%"
cy="50%"
innerRadius={70}
@@ -492,8 +522,10 @@ export default function Dashboard() {
onMouseLeave={onPieLeave}
isAnimationActive={false}
>
{data.orderStatusDistribution.map((entry: any, index: number) => {
const activePieIndex = data.orderStatusDistribution.findIndex((item: any) => item.name === activeStatus);
{sortedOrderStatus.map((entry: any, index: number) => {
const activePieIndex = sortedOrderStatus.findIndex((item: any) =>
(item.status || item.name) === activeStatus
);
const isActive = index === (hoverIndex !== undefined ? hoverIndex : activePieIndex);
return (
<Cell
@@ -524,7 +556,7 @@ export default function Dashboard() {
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{selectedData?.value.toLocaleString()}
{(selectedData?.value ?? 0).toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
@@ -558,16 +590,16 @@ export default function Dashboard() {
<TabsContent value="products" className="mt-0">
<div className="space-y-3">
{DUMMY_DATA.topProducts.map((product) => (
<div key={product.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
{(data.topProducts || DUMMY_DATA.topProducts).slice(0, 5).map((product: any, index: number) => (
<div key={product.product_id || product.id || index} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div className="flex items-center gap-3 flex-1">
<div className="text-2xl">{product.image}</div>
<div className="text-2xl">{product.image || '📦'}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{product.name}</div>
<div className="text-xs text-muted-foreground">{product.quantity} {__('sold')}</div>
<div className="text-xs text-muted-foreground">{product.items_sold || product.sales} {__('sold')}</div>
</div>
</div>
<div className="font-medium text-sm">{formatMoney(product.revenue)}</div>
<div className="text-sm font-medium">{formatMoney(product.revenue)}</div>
</div>
))}
</div>
@@ -575,13 +607,13 @@ export default function Dashboard() {
<TabsContent value="customers" className="mt-0">
<div className="space-y-3">
{DUMMY_DATA.topCustomers.map((customer) => (
<div key={customer.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
{(data.topCustomers || DUMMY_DATA.topCustomers).slice(0, 5).map((customer: any, index: number) => (
<div key={customer.customer_id || customer.id || index} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{customer.name}</div>
<div className="text-xs text-muted-foreground">{customer.orders} {__('orders')}</div>
<div className="text-xs text-muted-foreground">{customer.order_count || customer.orders} {__('orders')}</div>
</div>
<div className="font-medium text-sm">{formatMoney(customer.totalSpent)}</div>
<div className="text-sm font-medium">{formatMoney(customer.total_spent || customer.total)}</div>
</div>
))}
</div>