- Add dynamic date range generation based on actual order data - Implement custom date range picker with start/end date inputs - Unify date filtering logic between Analytics and ProfitAnalysis pages - Support multiple date formats (YYYY, YYYY-MM, YYYY-QX, custom ranges) - Remove hardcoded years for future-proof date selection - Enhance ProfitAnalysisService with custom date range support
747 lines
No EOL
31 KiB
TypeScript
747 lines
No EOL
31 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { useSelector } from 'react-redux';
|
|
import { RootState } from '../store';
|
|
import { calculateOrderPrintingCost, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
|
|
import { formatAustralianDate } from '../utils/dateFormatter';
|
|
import { TrendingUp, DollarSign, Package, Users, ShoppingCart, Download, Printer } from 'lucide-react';
|
|
|
|
const Analytics = () => {
|
|
const { orders } = useSelector((state: RootState) => state.orders);
|
|
const { products } = useSelector((state: RootState) => state.products);
|
|
const { customers } = useSelector((state: RootState) => state.customers);
|
|
const { expenses } = useSelector((state: RootState) => state.expenses);
|
|
|
|
const [dateRange, setDateRange] = useState(() => {
|
|
// Default to current year
|
|
return new Date().getFullYear().toString();
|
|
});
|
|
const [customStartDate, setCustomStartDate] = useState('');
|
|
const [customEndDate, setCustomEndDate] = useState('');
|
|
|
|
// Generate dynamic date range options based on actual order data
|
|
const dateRangeOptions = useMemo(() => {
|
|
const presets = [
|
|
{ value: 'all', label: 'All Time', type: 'preset' },
|
|
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
|
|
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
|
|
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
|
|
{ value: 'year', label: 'This Year (Calendar)', type: 'preset' },
|
|
{ value: 'custom', label: 'Custom Date Range', type: 'preset' }
|
|
];
|
|
|
|
if (!orders || orders.length === 0) {
|
|
return {
|
|
presets,
|
|
years: [],
|
|
quarters: [],
|
|
months: []
|
|
};
|
|
}
|
|
|
|
// Get date ranges from actual order data
|
|
const years = new Set<number>();
|
|
const months = new Set<string>();
|
|
const quarters = new Set<string>();
|
|
|
|
orders.forEach(order => {
|
|
if (order.dateOrdered) {
|
|
const date = new Date(order.dateOrdered);
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
const quarter = Math.floor(month / 3) + 1;
|
|
|
|
years.add(year);
|
|
months.add(`${year}-${String(month + 1).padStart(2, '0')}`);
|
|
quarters.add(`${year}-Q${quarter}`);
|
|
}
|
|
});
|
|
|
|
// Add year options (sorted newest first)
|
|
const yearOptions = Array.from(years)
|
|
.sort((a, b) => b - a)
|
|
.map(year => ({
|
|
value: year.toString(),
|
|
label: year.toString(),
|
|
type: 'year'
|
|
}));
|
|
|
|
// Add quarter options (last 8 quarters)
|
|
const quarterOptions = Array.from(quarters)
|
|
.sort((a, b) => b.localeCompare(a))
|
|
.slice(0, 8)
|
|
.map(quarter => ({
|
|
value: quarter,
|
|
label: quarter.replace('-', ' '),
|
|
type: 'quarter'
|
|
}));
|
|
|
|
// Add month options (last 24 months)
|
|
const monthOptions = Array.from(months)
|
|
.sort((a, b) => b.localeCompare(a))
|
|
.slice(0, 24)
|
|
.map(monthKey => {
|
|
const [year, month] = monthKey.split('-');
|
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
|
const label = date.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
|
return {
|
|
value: monthKey,
|
|
label,
|
|
type: 'month'
|
|
};
|
|
});
|
|
|
|
return {
|
|
presets,
|
|
years: yearOptions,
|
|
quarters: quarterOptions,
|
|
months: monthOptions
|
|
};
|
|
}, [orders]);
|
|
|
|
// Helper function to get updated printing cost using current product costs
|
|
const getUpdatedPrintingCost = (order: any) => {
|
|
const updatedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
|
return calculateOrderPrintingCost(updatedItems);
|
|
};
|
|
|
|
// Filter orders and expenses by selected date range using consistent logic
|
|
const filteredOrders = useMemo(() => {
|
|
if (!orders || dateRange === 'all') return orders || [];
|
|
|
|
const now = new Date();
|
|
return orders.filter(order => {
|
|
if (!order.dateOrdered) return false;
|
|
const orderDate = new Date(order.dateOrdered);
|
|
|
|
// Handle specific month format (e.g., "2026-03" for March 2026)
|
|
if (dateRange.match(/^\d{4}-\d{2}$/)) {
|
|
const [year, month] = dateRange.split('-');
|
|
return orderDate.getFullYear() === parseInt(year) &&
|
|
orderDate.getMonth() === parseInt(month) - 1;
|
|
}
|
|
|
|
// Handle quarter format (e.g., "2026-Q1")
|
|
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
|
|
const [year, quarter] = dateRange.split('-Q');
|
|
const targetYear = parseInt(year);
|
|
const targetQuarter = parseInt(quarter);
|
|
const orderYear = orderDate.getFullYear();
|
|
const orderQuarter = Math.floor(orderDate.getMonth() / 3) + 1;
|
|
return orderYear === targetYear && orderQuarter === targetQuarter;
|
|
}
|
|
|
|
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
|
|
if (dateRange.includes('_')) {
|
|
const [startDate, endDate] = dateRange.split('_');
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
end.setHours(23, 59, 59, 999); // Include the end date
|
|
return orderDate >= start && orderDate <= end;
|
|
}
|
|
|
|
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
|
if (dateRange.match(/^\d{4}$/)) {
|
|
return orderDate.getFullYear() === parseInt(dateRange);
|
|
}
|
|
|
|
// Handle legacy preset ranges (rolling periods from today)
|
|
const timeDiff = now.getTime() - orderDate.getTime();
|
|
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
|
|
|
|
switch (dateRange) {
|
|
case 'week':
|
|
return daysDiff >= 0 && daysDiff <= 7;
|
|
case 'month':
|
|
return daysDiff >= 0 && daysDiff <= 30;
|
|
case 'quarter':
|
|
return daysDiff >= 0 && daysDiff <= 90;
|
|
case 'year':
|
|
// For "This Year" option, show current calendar year
|
|
return orderDate.getFullYear() === now.getFullYear();
|
|
case 'custom':
|
|
// Handle custom date range using state variables
|
|
if (customStartDate && customEndDate) {
|
|
const start = new Date(customStartDate);
|
|
const end = new Date(customEndDate);
|
|
end.setHours(23, 59, 59, 999); // Include the end date
|
|
return orderDate >= start && orderDate <= end;
|
|
}
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}, [orders, dateRange]);
|
|
|
|
const filteredExpenses = useMemo(() => {
|
|
if (!expenses || dateRange === 'all') return expenses || [];
|
|
|
|
const now = new Date();
|
|
return expenses.filter(expense => {
|
|
if (!expense.date) return false;
|
|
const expenseDate = new Date(expense.date);
|
|
|
|
// Handle specific month format (e.g., "2026-03" for March 2026)
|
|
if (dateRange.match(/^\d{4}-\d{2}$/)) {
|
|
const [year, month] = dateRange.split('-');
|
|
return expenseDate.getFullYear() === parseInt(year) &&
|
|
expenseDate.getMonth() === parseInt(month) - 1;
|
|
}
|
|
|
|
// Handle quarter format (e.g., "2026-Q1")
|
|
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
|
|
const [year, quarter] = dateRange.split('-Q');
|
|
const targetYear = parseInt(year);
|
|
const targetQuarter = parseInt(quarter);
|
|
const expenseYear = expenseDate.getFullYear();
|
|
const expenseQuarter = Math.floor(expenseDate.getMonth() / 3) + 1;
|
|
return expenseYear === targetYear && expenseQuarter === targetQuarter;
|
|
}
|
|
|
|
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
|
|
if (dateRange.includes('_')) {
|
|
const [startDate, endDate] = dateRange.split('_');
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
end.setHours(23, 59, 59, 999); // Include the end date
|
|
return expenseDate >= start && expenseDate <= end;
|
|
}
|
|
|
|
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
|
if (dateRange.match(/^\d{4}$/)) {
|
|
return expenseDate.getFullYear() === parseInt(dateRange);
|
|
}
|
|
|
|
// Handle legacy preset ranges (rolling periods from today)
|
|
const timeDiff = now.getTime() - expenseDate.getTime();
|
|
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
|
|
|
|
switch (dateRange) {
|
|
case 'week':
|
|
return daysDiff >= 0 && daysDiff <= 7;
|
|
case 'month':
|
|
return daysDiff >= 0 && daysDiff <= 30;
|
|
case 'quarter':
|
|
return daysDiff >= 0 && daysDiff <= 90;
|
|
case 'year':
|
|
// For "This Year" option, show current calendar year
|
|
return expenseDate.getFullYear() === now.getFullYear();
|
|
case 'custom':
|
|
// Handle custom date range using state variables
|
|
if (customStartDate && customEndDate) {
|
|
const start = new Date(customStartDate);
|
|
const end = new Date(customEndDate);
|
|
end.setHours(23, 59, 59, 999); // Include the end date
|
|
return expenseDate >= start && expenseDate <= end;
|
|
}
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
}, [expenses, dateRange]);
|
|
|
|
// Calculate metrics with filtered data
|
|
const totalRevenue = filteredOrders.reduce((sum, order) => sum + (order?.total || 0), 0);
|
|
const totalPrintingCosts = filteredOrders.reduce((sum, order) => {
|
|
return sum + getUpdatedPrintingCost(order);
|
|
}, 0);
|
|
|
|
// Calculate expenses excluding only sale transaction fees to avoid double-counting
|
|
// (Sale transaction fees are already deducted from order totals in the CSV)
|
|
// But we DO want to include Etsy Ads, Listing Fees, GST, subscriptions, etc.
|
|
const totalExpenses = filteredExpenses.reduce((sum, expense) => {
|
|
// Only exclude transaction fees that are tied to specific orders (already deducted from order totals)
|
|
// Include: Listing Fees, Marketing/Ads, GST, and other business expenses
|
|
const isSaleTransactionFee = (
|
|
expense.vendor?.toLowerCase().includes('etsy') &&
|
|
expense.category?.toLowerCase() === 'transaction fees' &&
|
|
expense.reference && // Has an order reference
|
|
(expense.description?.toLowerCase().includes('transaction fee') ||
|
|
expense.description?.toLowerCase().includes('processing fee'))
|
|
);
|
|
|
|
if (isSaleTransactionFee) {
|
|
console.log('Excluding sale transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category);
|
|
return sum; // Skip sale transaction fees as they're already deducted from order totals
|
|
}
|
|
return sum + (expense?.amount || 0);
|
|
}, 0);
|
|
|
|
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
|
|
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
|
|
|
|
// Debug logging to verify the separation
|
|
console.log('=== EXPENSE SEPARATION DEBUG ===');
|
|
console.log('Date range filter:', dateRange);
|
|
console.log('Filtered orders count:', filteredOrders.length);
|
|
console.log('Filtered expenses count:', filteredExpenses.length);
|
|
|
|
const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0);
|
|
const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => {
|
|
const isSaleTransactionFee = (
|
|
expense.vendor?.toLowerCase().includes('etsy') &&
|
|
expense.category?.toLowerCase() === 'transaction fees' &&
|
|
expense.reference && // Has an order reference
|
|
(expense.description?.toLowerCase().includes('transaction fee') ||
|
|
expense.description?.toLowerCase().includes('processing fee'))
|
|
);
|
|
|
|
if (isSaleTransactionFee) {
|
|
console.log('Found sale transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category);
|
|
}
|
|
return isSaleTransactionFee ? sum + (expense.amount || 0) : sum;
|
|
}, 0);
|
|
|
|
console.log('All Expenses Total:', allExpensesTotal);
|
|
console.log('Etsy Transaction Fees Total:', transactionFeesTotal);
|
|
console.log('Expenses for Profit Calc (excluding transaction fees):', totalExpenses);
|
|
console.log('Difference (should equal transaction fees):', allExpensesTotal - totalExpenses);
|
|
console.log('Total Revenue:', totalRevenue);
|
|
console.log('Total Printing Costs:', totalPrintingCosts);
|
|
console.log('Net Profit:', netProfit);
|
|
console.log('Profit Margin:', profitMargin.toFixed(1) + '%');
|
|
|
|
const averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
|
|
const totalCustomers = customers?.length || 0;
|
|
|
|
// Create monthly revenue data from actual orders
|
|
const monthlyData = useMemo(() => {
|
|
if (!filteredOrders.length) return [];
|
|
|
|
const monthlyMap = new Map();
|
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
|
|
// Initialize all months with 0
|
|
months.forEach(month => {
|
|
monthlyMap.set(month, { month, revenue: 0, expenses: 0, profit: 0 });
|
|
});
|
|
|
|
// Aggregate orders by month
|
|
filteredOrders.forEach(order => {
|
|
if (order.dateOrdered) {
|
|
const date = new Date(order.dateOrdered);
|
|
const monthName = months[date.getMonth()];
|
|
const current = monthlyMap.get(monthName);
|
|
if (current) {
|
|
current.revenue += order.total || 0;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Aggregate expenses by month (showing all expenses for visibility)
|
|
filteredExpenses.forEach(expense => {
|
|
if (expense.date) {
|
|
const date = new Date(expense.date);
|
|
const monthName = months[date.getMonth()];
|
|
const current = monthlyMap.get(monthName);
|
|
|
|
if (current) {
|
|
// For monthly chart, exclude transaction fees to match profit calculation
|
|
const isEtsyTransactionFee = (
|
|
(expense.vendor?.toLowerCase() === 'etsy' &&
|
|
(expense.category?.toLowerCase().includes('transaction fee') ||
|
|
expense.description?.toLowerCase().includes('transaction fee')))
|
|
);
|
|
|
|
if (!isEtsyTransactionFee) {
|
|
current.expenses += expense.amount || 0;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Calculate profit for each month
|
|
monthlyMap.forEach(data => {
|
|
data.profit = data.revenue - data.expenses;
|
|
});
|
|
|
|
return Array.from(monthlyMap.values()).filter(data => data.revenue > 0 || data.expenses > 0);
|
|
}, [filteredOrders, filteredExpenses]);
|
|
|
|
// Create expense categories data from actual expenses
|
|
const expenseCategories = useMemo(() => {
|
|
const categories = new Map();
|
|
|
|
filteredExpenses.forEach(expense => {
|
|
// Show ALL expenses in categories (including Etsy transaction fees for visibility)
|
|
// The profit calculation will handle excluding transaction fees separately
|
|
const category = expense.category || 'Other';
|
|
const current = categories.get(category) || 0;
|
|
categories.set(category, current + (expense.amount || 0));
|
|
});
|
|
|
|
const totalExpenseAmount = Array.from(categories.values()).reduce((sum, amount) => sum + amount, 0);
|
|
|
|
const categoryData = Array.from(categories.entries()).map(([category, amount]) => ({
|
|
category,
|
|
amount,
|
|
percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0
|
|
})).sort((a, b) => b.amount - a.amount);
|
|
|
|
// Debug: Log detailed expense breakdown
|
|
console.log('=== DETAILED EXPENSE BREAKDOWN ===');
|
|
console.log('Total Expenses for Display (all expenses):', totalExpenseAmount.toFixed(2));
|
|
console.log('Total Expenses for Profit Calc (excluding transaction fees):', totalExpenses.toFixed(2));
|
|
console.log('Expense categories breakdown:');
|
|
categoryData.forEach(cat => {
|
|
console.log(` ${cat.category}: $${cat.amount.toFixed(2)} (${cat.percentage.toFixed(1)}%)`);
|
|
});
|
|
console.log('PROFIT CALCULATION:');
|
|
console.log(`Revenue: $${totalRevenue.toFixed(2)}`);
|
|
console.log(`Printing Costs: $${totalPrintingCosts.toFixed(2)}`);
|
|
console.log(`Other Expenses (excluding transaction fees): $${totalExpenses.toFixed(2)}`);
|
|
console.log(`Net Profit: $${totalRevenue.toFixed(2)} - $${totalPrintingCosts.toFixed(2)} - $${totalExpenses.toFixed(2)} = $${netProfit.toFixed(2)}`);
|
|
console.log('===');
|
|
|
|
return categoryData;
|
|
}, [filteredExpenses, totalExpenses, totalRevenue, totalPrintingCosts, netProfit]);
|
|
|
|
const topProducts = (products || [])
|
|
.filter(product => product && typeof product.price === 'number' && typeof product.costOfGoods === 'number')
|
|
.sort((a, b) => {
|
|
const aTotalCosts = a.costOfGoods + (a.printingCost || 0);
|
|
const bTotalCosts = b.costOfGoods + (b.printingCost || 0);
|
|
return (b.price - bTotalCosts) - (a.price - aTotalCosts);
|
|
})
|
|
.slice(0, 5);
|
|
|
|
const recentOrders = filteredOrders
|
|
.filter(order => order && order.dateOrdered)
|
|
.sort((a, b) => new Date(b.dateOrdered).getTime() - new Date(a.dateOrdered).getTime())
|
|
.slice(0, 5);
|
|
|
|
return (
|
|
<div className="p-6 max-w-7xl mx-auto">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900">Analytics</h1>
|
|
<p className="text-gray-600 mt-1">Business insights and performance metrics</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<select
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
value={dateRange}
|
|
onChange={(e) => setDateRange(e.target.value)}
|
|
>
|
|
{/* Preset Ranges */}
|
|
{dateRangeOptions.presets.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
|
|
{/* Years */}
|
|
{dateRangeOptions.years.length > 0 && (
|
|
<optgroup label="Years">
|
|
{dateRangeOptions.years.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
|
|
{/* Quarters */}
|
|
{dateRangeOptions.quarters.length > 0 && (
|
|
<optgroup label="Quarters">
|
|
{dateRangeOptions.quarters.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
|
|
{/* Months */}
|
|
{dateRangeOptions.months.length > 0 && (
|
|
<optgroup label="Months">
|
|
{dateRangeOptions.months.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
</select>
|
|
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
|
<Download className="w-4 h-4" />
|
|
Export Report
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Date Range Inputs */}
|
|
{dateRange === 'custom' && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
|
<div className="flex items-center gap-4">
|
|
<div>
|
|
<label htmlFor="startDate" className="block text-sm font-medium text-gray-700 mb-1">
|
|
Start Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
id="startDate"
|
|
value={customStartDate}
|
|
onChange={(e) => setCustomStartDate(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700 mb-1">
|
|
End Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
id="endDate"
|
|
value={customEndDate}
|
|
onChange={(e) => setCustomEndDate(e.target.value)}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
<div className="pt-6">
|
|
<button
|
|
onClick={() => {
|
|
if (customStartDate && customEndDate) {
|
|
setDateRange(`${customStartDate}_${customEndDate}`);
|
|
}
|
|
}}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
|
disabled={!customStartDate || !customEndDate}
|
|
>
|
|
Apply Range
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Key Metrics */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">Total Revenue</p>
|
|
<p className="text-2xl font-semibold text-gray-900">${totalRevenue.toFixed(2)}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<DollarSign className="h-8 w-8 text-green-500" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm">
|
|
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
|
<span className="text-green-600 font-medium">12.5%</span>
|
|
<span className="text-gray-600 ml-1">vs last period</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">Net Profit</p>
|
|
<p className="text-2xl font-semibold text-gray-900">${netProfit.toFixed(2)}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<TrendingUp className="h-8 w-8 text-blue-500" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm">
|
|
<span className="text-gray-600">Margin: </span>
|
|
<span className="text-blue-600 font-medium ml-1">{profitMargin.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">Orders</p>
|
|
<p className="text-2xl font-semibold text-gray-900">{filteredOrders.length}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<ShoppingCart className="h-8 w-8 text-purple-500" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm">
|
|
<span className="text-gray-600">AOV: </span>
|
|
<span className="text-purple-600 font-medium ml-1">${averageOrderValue.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">Customers</p>
|
|
<p className="text-2xl font-semibold text-gray-900">{totalCustomers}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<Users className="h-8 w-8 text-orange-500" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm">
|
|
<span className="text-gray-600">Repeat rate: </span>
|
|
<span className="text-orange-600 font-medium ml-1">
|
|
{totalCustomers > 0 && customers ?
|
|
((customers.filter(c => c && c.totalOrders > 1).length / totalCustomers) * 100).toFixed(1) :
|
|
0}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-500">Printing Costs</p>
|
|
<p className="text-2xl font-semibold text-gray-900">${totalPrintingCosts.toFixed(2)}</p>
|
|
</div>
|
|
<div className="flex-shrink-0">
|
|
<Printer className="h-8 w-8 text-indigo-500" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center text-sm">
|
|
<span className="text-gray-600">Avg per order: </span>
|
|
<span className="text-indigo-600 font-medium ml-1">
|
|
${orders?.length > 0 ? (totalPrintingCosts / orders.length).toFixed(2) : '0.00'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
|
{/* Revenue Chart */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Revenue Trend</h3>
|
|
<div className="h-64 flex items-end justify-between space-x-2">
|
|
{monthlyData.length > 0 ? monthlyData.map((data, index) => {
|
|
const maxRevenue = Math.max(...monthlyData.map(d => d.revenue));
|
|
const height = maxRevenue > 0 ? (data.revenue / maxRevenue) * 100 : 0;
|
|
|
|
return (
|
|
<div key={index} className="flex-1 flex flex-col items-center">
|
|
<div
|
|
className="w-full bg-blue-500 rounded-t"
|
|
style={{ height: `${height}%`, minHeight: data.revenue > 0 ? '8px' : '0px' }}
|
|
title={`${data.month}: $${data.revenue.toFixed(2)}`}
|
|
/>
|
|
<span className="text-xs text-gray-500 mt-2">{data.month}</span>
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div className="flex items-center justify-center w-full h-32 text-gray-500">
|
|
No revenue data for selected period
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 flex justify-between text-sm text-gray-600">
|
|
<span>Revenue by Month</span>
|
|
{monthlyData.length > 0 && (
|
|
<span>Peak: ${Math.max(...monthlyData.map(d => d.revenue)).toFixed(2)}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expense Breakdown */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Expense Categories</h3>
|
|
<div className="space-y-4">
|
|
{expenseCategories.length > 0 ? expenseCategories.map((category, index) => {
|
|
const colors = ['bg-blue-600', 'bg-green-600', 'bg-purple-600', 'bg-orange-600', 'bg-red-600', 'bg-indigo-600'];
|
|
const colorClass = colors[index % colors.length];
|
|
|
|
return (
|
|
<div key={category.category} className="flex justify-between items-center">
|
|
<span className="text-sm text-gray-600">{category.category}</span>
|
|
<div className="flex items-center">
|
|
<div className="flex flex-col items-center mr-3">
|
|
<span className="text-xs text-gray-500 mb-1">${category.amount.toFixed(2)}</span>
|
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className={`${colorClass} h-2 rounded-full`}
|
|
style={{ width: `${category.percentage}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span className="text-sm font-medium">{category.percentage.toFixed(1)}%</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}) : (
|
|
<div className="flex items-center justify-center h-32 text-gray-500">
|
|
No expense data for selected period
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tables Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Top Products */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Top Profitable Products</h3>
|
|
<div className="space-y-3">
|
|
{topProducts.length > 0 ? topProducts.map((product, index) => (
|
|
<div key={product?._id || index} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
|
<div className="flex items-center">
|
|
<span className="text-sm font-medium text-gray-500 mr-3">#{index + 1}</span>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900 truncate max-w-48">{product?.title || 'Unknown Product'}</p>
|
|
<p className="text-xs text-gray-500">SKU: {product?.sku || 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-medium text-green-600">
|
|
${((product?.price || 0) - (product?.costOfGoods || 0) - (product?.printingCost || 0)).toFixed(2)}
|
|
</p>
|
|
<p className="text-xs text-gray-500">profit per item</p>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<div className="text-center text-gray-500 py-4">
|
|
<p>No products found</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Orders */}
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4">Recent Orders</h3>
|
|
<div className="space-y-3">
|
|
{recentOrders.length > 0 ? recentOrders.map((order) => (
|
|
<div key={order?._id || Math.random()} className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
|
<Package className="w-4 h-4 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900">#{order?.orderNumber || 'N/A'}</p>
|
|
<p className="text-xs text-gray-500">{order?.dateOrdered ? formatAustralianDate(order.dateOrdered) : 'N/A'}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm font-medium text-gray-900">${(order?.total || 0).toFixed(2)}</p>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
order?.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
|
order?.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
|
|
order?.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
|
|
'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{order?.status || 'unknown'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)) : (
|
|
<div className="text-center text-gray-500 py-4">
|
|
<p>No recent orders</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Analytics; |