Files
WooNooW/admin-spa/src/routes/Dashboard/Customers.tsx
dwindown 0dace90597 refactor: Smart contextual headers - only when they add value
Implemented intelligent header rules based on user feedback.

Problem Analysis:
1. Dashboard submenu tabs already show page names (Overview, Revenue, Orders...)
2. Showing "Orders" header is ambiguous (Analytics or Management?)
3. Wasted vertical space for redundant information
4. FAB already handles actions on management pages

Solution: Headers ONLY When They Add Value

Rules Implemented:

1. Dashboard Pages: NO HEADERS
   - Submenu tabs are sufficient
   - Saves vertical space
   - No ambiguity

   Before:
   Dashboard → Overview  = "Dashboard" header (redundant!)
   Dashboard → Orders    = "Orders" header (confusing!)

   After:
   Dashboard → Overview  = No header (tabs show "Overview")
   Dashboard → Orders    = No header (tabs show "Orders")

2. Settings Pages: HEADERS ONLY WITH ACTIONS
   - Store Details + [Save] = Show header ✓
   - Payments + [Refresh]   = Show header ✓
   - Pages without actions  = No header (save space)

   Logic: If there is an action button, we need a place to put it → header
          If no action button, header is just wasting space → remove it

3. Management Pages: NO HEADERS
   - FAB handles actions ([+ Add Order])
   - No need for redundant header with action button

4. Payments Exception: REMOVED
   - Treat Payments like any other settings page
   - Has action (Refresh) = show header
   - Consistent with other pages

Implementation:

Dashboard Pages (7 files):
- Removed usePageHeader hook
- Removed useEffect for setting header
- Removed unused imports (useEffect, usePageHeader)
- Result: Clean, no headers, tabs are enough

PageHeader Component:
- Removed Payments special case detection
- Removed useLocation import
- Simplified logic: hideOnDesktop prop only

SettingsLayout Component:
- Changed logic: Only set header when onSave OR action exists
- If no action: clearPageHeader() instead of setPageHeader(title)
- Result: Headers only appear when needed

Benefits:

 Saves vertical space (no redundant headers)
 No ambiguity (Dashboard Orders vs Orders Management)
 Consistent logic (action = header, no action = no header)
 Cleaner UI (less visual clutter)
 FAB handles management page actions

Files Modified:
- Dashboard/index.tsx (Overview)
- Dashboard/Revenue.tsx
- Dashboard/Orders.tsx
- Dashboard/Products.tsx
- Dashboard/Customers.tsx
- Dashboard/Coupons.tsx
- Dashboard/Taxes.tsx
- PageHeader.tsx
- SettingsLayout.tsx

Result: Smart headers that only appear when they add value! 🎯
2025-11-06 23:11:59 +07:00

467 lines
18 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { Users, TrendingUp, DollarSign, ShoppingCart, UserPlus, UserCheck, Info } from 'lucide-react';
import { __ } from '@/lib/i18n';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import { useDashboardPeriod } from '@/hooks/useDashboardPeriod';
import { useCustomersAnalytics } from '@/hooks/useAnalytics';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { StatCard } from './components/StatCard';
import { ChartCard } from './components/ChartCard';
import { DataTable, Column } from './components/DataTable';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DUMMY_CUSTOMERS_DATA, CustomersData, TopCustomer } from './data/dummyCustomers';
export default function CustomersAnalytics() {
const { period } = useDashboardPeriod();
const store = getStoreCurrency();
// Fetch real data or use dummy data based on toggle
const { data, isLoading, error, refetch } = useCustomersAnalytics(DUMMY_CUSTOMERS_DATA);
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS!
// Filter chart data by period
const chartData = useMemo(() => {
if (!data) return [];
return period === 'all' ? data.acquisition_chart : data.acquisition_chart.slice(-parseInt(period));
}, [data, period]);
// Calculate period metrics
const periodMetrics = useMemo(() => {
// Store-level data (not affected by period)
const totalCustomersStoreLevel = data.overview.total_customers; // All-time total
const avgLtvStoreLevel = data.overview.avg_ltv; // Lifetime value is cumulative
const avgOrdersPerCustomer = data.overview.avg_orders_per_customer; // Average ratio
if (period === 'all') {
const totalNew = data.acquisition_chart.reduce((sum: number, d: any) => sum + d.new_customers, 0);
const totalReturning = data.acquisition_chart.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
const totalInPeriod = totalNew + totalReturning;
return {
// Store-level (not affected)
total_customers: totalCustomersStoreLevel,
avg_ltv: avgLtvStoreLevel,
avg_orders_per_customer: avgOrdersPerCustomer,
// Period-based
new_customers: totalNew,
returning_customers: totalReturning,
retention_rate: totalInPeriod > 0 ? (totalReturning / totalInPeriod) * 100 : 0,
// No comparison for "all time"
new_customers_change: undefined,
retention_rate_change: undefined,
};
}
const periodData = data.acquisition_chart.slice(-parseInt(period));
const previousData = data.acquisition_chart.slice(-parseInt(period) * 2, -parseInt(period));
const totalNew = periodData.reduce((sum: number, d: any) => sum + d.new_customers, 0);
const totalReturning = periodData.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
const totalInPeriod = totalNew + totalReturning;
const prevTotalNew = previousData.reduce((sum: number, d: any) => sum + d.new_customers, 0);
const prevTotalReturning = previousData.reduce((sum: number, d: any) => sum + d.returning_customers, 0);
const prevTotalInPeriod = prevTotalNew + prevTotalReturning;
const retentionRate = totalInPeriod > 0 ? (totalReturning / totalInPeriod) * 100 : 0;
const prevRetentionRate = prevTotalInPeriod > 0 ? (prevTotalReturning / prevTotalInPeriod) * 100 : 0;
return {
// Store-level (not affected)
total_customers: totalCustomersStoreLevel,
avg_ltv: avgLtvStoreLevel,
avg_orders_per_customer: avgOrdersPerCustomer,
// Period-based
new_customers: totalNew,
returning_customers: totalReturning,
retention_rate: retentionRate,
// Comparisons
new_customers_change: prevTotalNew > 0 ? ((totalNew - prevTotalNew) / prevTotalNew) * 100 : 0,
retention_rate_change: prevRetentionRate > 0 ? ((retentionRate - prevRetentionRate) / prevRetentionRate) * 100 : 0,
};
}, [data.acquisition_chart, period, data.overview]);
// Format money with M/B abbreviations (translatable)
const formatMoneyAxis = (value: number) => {
if (value >= 1000000000) {
return `${(value / 1000000000).toFixed(1)}${__('B')}`;
}
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}${__('M')}`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}${__('K')}`;
}
return value.toString();
};
// Format currency
const formatCurrency = (value: number) => {
return formatMoney(value, {
currency: store.currency,
symbol: store.symbol,
thousandSep: store.thousand_sep,
decimalSep: store.decimal_sep,
decimals: 0,
preferSymbol: true,
});
};
// Format money range strings (e.g., "Rp1.000.000 - Rp5.000.000" -> "Rp1.0M - Rp5.0M")
const formatMoneyRange = (rangeStr: string) => {
// Extract numbers from the range string
const numbers = rangeStr.match(/\d+(?:[.,]\d+)*/g);
if (!numbers) return rangeStr;
// Parse and format each number
const formatted = numbers.map((numStr: string) => {
const num = parseInt(numStr.replace(/[.,]/g, ''));
return store.symbol + formatMoneyAxis(num).replace(/[^\d.KMB]/g, '');
});
// Reconstruct the range
if (rangeStr.includes('-')) {
return `${formatted[0]} - ${formatted[1]}`;
} else if (rangeStr.startsWith('<')) {
return `< ${formatted[0]}`;
} else if (rangeStr.startsWith('>')) {
return `> ${formatted[0]}`;
}
return formatted.join(' - ');
};
// Filter top customers by period (for revenue in period, not LTV)
const filteredTopCustomers = useMemo(() => {
if (!data || !data.top_customers) return [];
if (period === 'all') {
return data.top_customers; // Show all-time data
}
// Scale customer spending by period factor for demonstration
// In real implementation, this would fetch period-specific data from API
const factor = parseInt(period) / 30;
return data.top_customers.map((customer: any) => ({
...customer,
total_spent: Math.round(customer.total_spent * factor),
orders: Math.round(customer.orders * factor),
}));
}, [data, period]);
// Debug logging
console.log('[CustomersAnalytics] State:', {
isLoading,
hasError: !!error,
errorMessage: error?.message,
hasData: !!data,
dataKeys: data ? Object.keys(data) : []
});
// Show loading state
if (isLoading) {
console.log('[CustomersAnalytics] Rendering loading state');
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">{__('Loading analytics...')}</p>
</div>
</div>
);
}
// Show error state with clear message and retry button
if (error) {
console.log('[CustomersAnalytics] Rendering error state:', error);
return (
<ErrorCard
title={__('Failed to load customer analytics')}
message={getPageLoadErrorMessage(error)}
onRetry={() => refetch()}
/>
);
}
console.log('[CustomersAnalytics] Rendering normal content');
// Table columns
const customerColumns: Column<TopCustomer>[] = [
{ key: 'name', label: __('Customer'), sortable: true },
{ key: 'email', label: __('Email'), sortable: true },
{
key: 'orders',
label: __('Orders'),
sortable: true,
align: 'right',
},
{
key: 'total_spent',
label: __('Total Spent'),
sortable: true,
align: 'right',
render: (value) => formatCurrency(value),
},
{
key: 'avg_order_value',
label: __('Avg Order'),
sortable: true,
align: 'right',
render: (value) => formatCurrency(value),
},
{
key: 'segment',
label: __('Segment'),
sortable: true,
render: (value) => {
const colors: Record<string, string> = {
vip: 'bg-purple-100 text-purple-800',
returning: 'bg-blue-100 text-blue-800',
new: 'bg-green-100 text-green-800',
at_risk: 'bg-red-100 text-red-800',
};
const labels: Record<string, string> = {
vip: __('VIP'),
returning: __('Returning'),
new: __('New'),
at_risk: __('At Risk'),
};
return (
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${colors[value] || ''}`}>
{labels[value] || value}
</span>
);
},
},
];
return (
<div className="space-y-6 lg:p-6 pb-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold">{__('Customers Analytics')}</h1>
<p className="text-sm text-muted-foreground">{__('Customer behavior and lifetime value')}</p>
</div>
{/* Metric Cards - Row 1: Period-based metrics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title={__('New Customers')}
value={periodMetrics.new_customers}
change={periodMetrics.new_customers_change}
icon={UserPlus}
format="number"
period={period}
/>
<StatCard
title={__('Retention Rate')}
value={periodMetrics.retention_rate}
change={periodMetrics.retention_rate_change}
icon={UserCheck}
format="percent"
period={period}
/>
<StatCard
title={__('Avg Orders/Customer')}
value={periodMetrics.avg_orders_per_customer}
icon={ShoppingCart}
format="number"
/>
<StatCard
title={__('Avg Lifetime Value')}
value={periodMetrics.avg_ltv}
icon={DollarSign}
format="money"
/>
</div>
{/* Customer Segments - Row 2: Store-level + Period segments */}
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center gap-3 mb-2">
<Users className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-sm">{__('Total Customers')}</h3>
</div>
<p className="text-3xl font-bold">{periodMetrics.total_customers}</p>
<p className="text-sm text-muted-foreground mt-1">
{__('All-time total')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center gap-3 mb-2">
<UserCheck className="w-5 h-5 text-green-600" />
<h3 className="font-semibold text-sm">{__('Returning')}</h3>
</div>
<p className="text-3xl font-bold">{periodMetrics.returning_customers}</p>
<p className="text-sm text-muted-foreground mt-1">
{__('In selected period')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
<h3 className="font-semibold text-sm">{__('VIP Customers')}</h3>
</div>
<div className="group relative">
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 p-3 bg-popover border rounded-lg shadow-lg text-xs">
<p className="font-medium mb-1">{__('VIP Qualification:')}</p>
<p className="text-muted-foreground">{__('Customers with 10+ orders OR lifetime value > Rp5.000.000')}</p>
</div>
</div>
</div>
<p className="text-3xl font-bold">{data.segments.vip}</p>
<p className="text-sm text-muted-foreground mt-1">
{((data.segments.vip / data.overview.total_customers) * 100).toFixed(1)}% {__('of total')}
</p>
</div>
<div className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-red-600" />
<h3 className="font-semibold text-sm">{__('At Risk')}</h3>
</div>
<div className="group relative">
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 p-3 bg-popover border rounded-lg shadow-lg text-xs">
<p className="font-medium mb-1">{__('At Risk Qualification:')}</p>
<p className="text-muted-foreground">{__('Customers with no orders in the last 90 days')}</p>
</div>
</div>
</div>
<p className="text-3xl font-bold">{data.segments.at_risk}</p>
<p className="text-sm text-muted-foreground mt-1">
{((data.segments.at_risk / data.overview.total_customers) * 100).toFixed(1)}% {__('of total')}
</p>
</div>
</div>
{/* Customer Acquisition Chart */}
<ChartCard
title={__('Customer Acquisition')}
description={__('New vs returning customers over time')}
>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
className="text-xs"
tickFormatter={(value) => {
const date = new Date(value);
return `${date.getMonth() + 1}/${date.getDate()}`;
}}
/>
<YAxis className="text-xs" />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || !payload.length) return null;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="text-sm font-medium mb-2">
{new Date(payload[0].payload.date).toLocaleDateString()}
</p>
{payload.map((entry: any) => (
<div key={entry.dataKey} className="flex items-center justify-between gap-4 text-sm">
<span style={{ color: entry.color }}>{entry.name}:</span>
<span className="font-medium">{entry.value}</span>
</div>
))}
</div>
);
}}
/>
<Legend />
<Line
type="monotone"
dataKey="new_customers"
name={__('New Customers')}
stroke="#10b981"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="returning_customers"
name={__('Returning Customers')}
stroke="#3b82f6"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
{/* Two Column Layout */}
<div className="grid gap-6 md:grid-cols-2">
{/* Top Customers */}
<ChartCard
title={__('Top Customers')}
description={__('Highest spending customers')}
>
<DataTable
data={filteredTopCustomers.slice(0, 5)}
columns={customerColumns}
/>
</ChartCard>
{/* LTV Distribution */}
<ChartCard
title={__('Lifetime Value Distribution')}
description={__('Customer segments by total spend')}
>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data.ltv_distribution}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="range"
className="text-xs"
angle={-45}
textAnchor="end"
height={80}
tickFormatter={formatMoneyRange}
/>
<YAxis className="text-xs" />
<Tooltip
content={({ active, payload }) => {
if (!active || !payload || !payload.length) return null;
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="text-sm font-medium mb-1">{data.range}</p>
<p className="text-sm">
{__('Customers')}: <span className="font-medium">{data.count}</span>
</p>
<p className="text-sm">
{__('Percentage')}: <span className="font-medium">{data.percentage.toFixed(1)}%</span>
</p>
</div>
);
}}
/>
<Bar dataKey="count" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* All Customers Table */}
<ChartCard
title={__('All Top Customers')}
description={__('Complete list of top spending customers')}
>
<DataTable
data={filteredTopCustomers}
columns={customerColumns}
/>
</ChartCard>
</div>
);
}