Features: - React + TypeScript frontend with Tailwind CSS - Node.js + Express backend with TypeScript - Comprehensive order tracking and management - Product catalog with inventory tracking - Customer data management - Expense tracking and categorization - Advanced Profit Analysis Dashboard with: - Real-time profit metrics and KPI visualization - Detailed order-level profit breakdown - Product performance analysis - Enhanced time range filtering (monthly, quarterly, yearly) - Interactive expandable order analysis - Performance categorization and color coding - CSV import functionality for Etsy statements - PDF parsing capabilities - Redux state management with persistence - Responsive design with mobile support - Australian date formatting and currency display
473 lines
No EOL
15 KiB
TypeScript
473 lines
No EOL
15 KiB
TypeScript
import { Order } from '../store/slices/orderSlice';
|
|
import { Product } from '../store/slices/productSlice';
|
|
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
|
|
|
|
export interface ProfitMetrics {
|
|
totalRevenue: number;
|
|
totalPrintingCosts: number;
|
|
totalProfit: number;
|
|
averageMargin: number;
|
|
orderCount: number;
|
|
profitableOrders: number;
|
|
averageOrderValue: number;
|
|
averageCost: number;
|
|
}
|
|
|
|
export interface ProductProfitability {
|
|
name: string;
|
|
revenue: number;
|
|
cost: number;
|
|
profit: number;
|
|
margin: number;
|
|
orders: number;
|
|
quantity: number;
|
|
averageOrderValue: number;
|
|
profitPerUnit: number;
|
|
}
|
|
|
|
export interface MonthlyProfit {
|
|
month: string;
|
|
revenue: number;
|
|
costs: number;
|
|
profit: number;
|
|
margin: number;
|
|
orderCount: number;
|
|
}
|
|
|
|
export interface OrderProfitAnalysis {
|
|
orderId: string;
|
|
orderNumber: string;
|
|
date: string;
|
|
customerName: string;
|
|
revenue: number;
|
|
printingCosts: number;
|
|
profit: number;
|
|
margin: number;
|
|
items: OrderItemAnalysis[];
|
|
profitCategory: 'excellent' | 'good' | 'average' | 'poor' | 'loss';
|
|
}
|
|
|
|
export interface OrderItemAnalysis {
|
|
title: string;
|
|
quantity: number;
|
|
price: number;
|
|
printingCost: number;
|
|
itemRevenue: number;
|
|
itemCost: number;
|
|
itemProfit: number;
|
|
itemMargin: number;
|
|
}
|
|
|
|
export interface DateRangeOption {
|
|
value: string;
|
|
label: string;
|
|
type: 'preset' | 'month' | 'quarter' | 'year' | 'custom';
|
|
}
|
|
|
|
export class ProfitAnalysisService {
|
|
/**
|
|
* Calculate comprehensive profit metrics from orders
|
|
*/
|
|
static calculateProfitMetrics(orders: Order[], products: Product[]): ProfitMetrics {
|
|
if (!orders || orders.length === 0) {
|
|
return {
|
|
totalRevenue: 0,
|
|
totalPrintingCosts: 0,
|
|
totalProfit: 0,
|
|
averageMargin: 0,
|
|
orderCount: 0,
|
|
profitableOrders: 0,
|
|
averageOrderValue: 0,
|
|
averageCost: 0
|
|
};
|
|
}
|
|
|
|
let totalRevenue = 0;
|
|
let totalPrintingCosts = 0;
|
|
let totalProfit = 0;
|
|
let profitableOrderCount = 0;
|
|
|
|
orders.forEach(order => {
|
|
const revenue = order.total || 0;
|
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
|
const printingCost = calculateOrderPrintingCost(enrichedItems);
|
|
const profit = calculateOrderProfit(enrichedItems, revenue);
|
|
|
|
totalRevenue += revenue;
|
|
totalPrintingCosts += printingCost;
|
|
totalProfit += profit;
|
|
|
|
if (profit > 0) profitableOrderCount++;
|
|
});
|
|
|
|
const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
|
|
const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
|
|
const averageCost = orders.length > 0 ? totalPrintingCosts / orders.length : 0;
|
|
|
|
return {
|
|
totalRevenue,
|
|
totalPrintingCosts,
|
|
totalProfit,
|
|
averageMargin,
|
|
orderCount: orders.length,
|
|
profitableOrders: profitableOrderCount,
|
|
averageOrderValue,
|
|
averageCost
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze profitability by product
|
|
*/
|
|
static analyzeProductProfitability(orders: Order[], products: Product[]): ProductProfitability[] {
|
|
if (!orders || !products) return [];
|
|
|
|
const productStats = new Map<string, ProductProfitability>();
|
|
|
|
orders.forEach(order => {
|
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
|
|
|
|
enrichedItems.forEach(item => {
|
|
const revenue = (item.price || 0) * (item.quantity || 1);
|
|
const cost = (item.printingCost || 0) * (item.quantity || 1);
|
|
const profit = revenue - cost;
|
|
|
|
if (!productStats.has(item.title)) {
|
|
productStats.set(item.title, {
|
|
name: item.title,
|
|
revenue: 0,
|
|
cost: 0,
|
|
profit: 0,
|
|
margin: 0,
|
|
orders: 0,
|
|
quantity: 0,
|
|
averageOrderValue: 0,
|
|
profitPerUnit: 0
|
|
});
|
|
}
|
|
|
|
const stats = productStats.get(item.title)!;
|
|
stats.revenue += revenue;
|
|
stats.cost += cost;
|
|
stats.profit += profit;
|
|
stats.orders += 1;
|
|
stats.quantity += item.quantity || 1;
|
|
});
|
|
});
|
|
|
|
// Calculate derived metrics
|
|
productStats.forEach(stats => {
|
|
stats.margin = stats.revenue > 0 ? (stats.profit / stats.revenue) * 100 : 0;
|
|
stats.averageOrderValue = stats.orders > 0 ? stats.revenue / stats.orders : 0;
|
|
stats.profitPerUnit = stats.quantity > 0 ? stats.profit / stats.quantity : 0;
|
|
});
|
|
|
|
return Array.from(productStats.values())
|
|
.sort((a, b) => b.profit - a.profit);
|
|
}
|
|
|
|
/**
|
|
* Calculate monthly profit trends
|
|
*/
|
|
static calculateMonthlyTrends(orders: Order[], products: Product[]): MonthlyProfit[] {
|
|
if (!orders || orders.length === 0) return [];
|
|
|
|
const monthlyData = new Map<string, MonthlyProfit>();
|
|
|
|
orders.forEach(order => {
|
|
const orderDate = new Date(order.dateOrdered);
|
|
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
|
|
const monthName = orderDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
|
|
|
const revenue = order.total || 0;
|
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
|
const costs = calculateOrderPrintingCost(enrichedItems);
|
|
const profit = revenue - costs;
|
|
|
|
if (!monthlyData.has(monthKey)) {
|
|
monthlyData.set(monthKey, {
|
|
month: monthName,
|
|
revenue: 0,
|
|
costs: 0,
|
|
profit: 0,
|
|
margin: 0,
|
|
orderCount: 0
|
|
});
|
|
}
|
|
|
|
const monthStats = monthlyData.get(monthKey)!;
|
|
monthStats.revenue += revenue;
|
|
monthStats.costs += costs;
|
|
monthStats.profit += profit;
|
|
monthStats.orderCount += 1;
|
|
});
|
|
|
|
// Calculate margins and sort by date
|
|
const results = Array.from(monthlyData.entries())
|
|
.map(([key, data]) => {
|
|
data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0;
|
|
return { key, ...data };
|
|
})
|
|
.sort((a, b) => a.key.localeCompare(b.key))
|
|
.map(({ key, ...data }) => data);
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Filter orders by date range
|
|
*/
|
|
static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] {
|
|
if (dateRange === 'all' || !orders) return orders;
|
|
|
|
const now = new Date();
|
|
const filteredOrders = orders.filter(order => {
|
|
const orderDate = new Date(order.dateOrdered);
|
|
|
|
// Handle specific month format (e.g., "2025-07" for July 2025)
|
|
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., "2025-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 year format (e.g., "2025")
|
|
if (dateRange.match(/^\d{4}$/)) {
|
|
return orderDate.getFullYear() === parseInt(dateRange);
|
|
}
|
|
|
|
// 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);
|
|
return orderDate >= start && orderDate <= end;
|
|
}
|
|
|
|
// Handle legacy preset ranges
|
|
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':
|
|
return daysDiff >= 0 && daysDiff <= 365;
|
|
default:
|
|
return true;
|
|
}
|
|
});
|
|
|
|
return filteredOrders;
|
|
}
|
|
|
|
/**
|
|
* Generate available date range options based on order data
|
|
*/
|
|
static generateDateRangeOptions(orders: Order[]): DateRangeOption[] {
|
|
if (!orders || orders.length === 0) {
|
|
return [{ value: 'all', label: 'All Time', type: 'preset' }];
|
|
}
|
|
|
|
const options: DateRangeOption[] = [
|
|
{ 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: 'Last 365 Days', type: 'preset' }
|
|
];
|
|
|
|
// Get unique months from orders
|
|
const monthsSet = new Set<string>();
|
|
const quartersSet = new Set<string>();
|
|
const yearsSet = new Set<string>();
|
|
|
|
orders.forEach(order => {
|
|
const date = new Date(order.dateOrdered);
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth();
|
|
const quarter = Math.floor(month / 3) + 1;
|
|
|
|
// Add specific months
|
|
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
|
|
monthsSet.add(monthKey);
|
|
|
|
// Add quarters
|
|
const quarterKey = `${year}-Q${quarter}`;
|
|
quartersSet.add(quarterKey);
|
|
|
|
// Add years
|
|
yearsSet.add(year.toString());
|
|
});
|
|
|
|
// Convert to options and sort
|
|
const monthOptions: DateRangeOption[] = Array.from(monthsSet)
|
|
.sort((a, b) => b.localeCompare(a)) // Newest first
|
|
.slice(0, 24) // Limit to last 24 months
|
|
.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' as const };
|
|
});
|
|
|
|
const quarterOptions: DateRangeOption[] = Array.from(quartersSet)
|
|
.sort((a, b) => b.localeCompare(a)) // Newest first
|
|
.slice(0, 8) // Limit to last 8 quarters
|
|
.map(quarterKey => ({
|
|
value: quarterKey,
|
|
label: quarterKey.replace('-', ' '),
|
|
type: 'quarter' as const
|
|
}));
|
|
|
|
const yearOptions: DateRangeOption[] = Array.from(yearsSet)
|
|
.sort((a, b) => parseInt(b) - parseInt(a)) // Newest first
|
|
.slice(0, 5) // Limit to last 5 years
|
|
.map(year => ({
|
|
value: year,
|
|
label: year,
|
|
type: 'year' as const
|
|
}));
|
|
|
|
// Combine all options with separators
|
|
return [
|
|
...options,
|
|
...monthOptions,
|
|
...quarterOptions,
|
|
...yearOptions
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get date range label for display
|
|
*/
|
|
static getDateRangeLabel(dateRange: string, orders: Order[]): string {
|
|
const options = this.generateDateRangeOptions(orders);
|
|
const option = options.find(opt => opt.value === dateRange);
|
|
return option ? option.label : dateRange;
|
|
}
|
|
|
|
/**
|
|
* Get top/bottom performing products
|
|
*/
|
|
static getTopPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
|
|
return this.analyzeProductProfitability(orders, products).slice(0, count);
|
|
}
|
|
|
|
static getWorstPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
|
|
return this.analyzeProductProfitability(orders, products)
|
|
.sort((a, b) => a.profit - b.profit)
|
|
.slice(0, count);
|
|
}
|
|
|
|
/**
|
|
* Calculate profit margin categories
|
|
*/
|
|
static categorizeByMargin(products: ProductProfitability[]): {
|
|
excellent: ProductProfitability[]; // >50%
|
|
good: ProductProfitability[]; // 30-50%
|
|
average: ProductProfitability[]; // 15-30%
|
|
poor: ProductProfitability[]; // 0-15%
|
|
loss: ProductProfitability[]; // <0%
|
|
} {
|
|
return {
|
|
excellent: products.filter(p => p.margin > 50),
|
|
good: products.filter(p => p.margin > 30 && p.margin <= 50),
|
|
average: products.filter(p => p.margin > 15 && p.margin <= 30),
|
|
poor: products.filter(p => p.margin > 0 && p.margin <= 15),
|
|
loss: products.filter(p => p.margin <= 0)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze individual order profitability with item breakdown
|
|
*/
|
|
static analyzeOrderProfitability(orders: Order[], products: Product[]): OrderProfitAnalysis[] {
|
|
if (!orders || !products) return [];
|
|
|
|
return orders.map(order => {
|
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
|
|
const revenue = order.total || 0;
|
|
const printingCosts = calculateOrderPrintingCost(enrichedItems);
|
|
const profit = calculateOrderProfit(enrichedItems, revenue);
|
|
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
|
|
|
|
// Analyze each item
|
|
const itemAnalyses: OrderItemAnalysis[] = enrichedItems.map(item => {
|
|
const itemRevenue = (item.price || 0) * (item.quantity || 1);
|
|
const itemCost = (item.printingCost || 0) * (item.quantity || 1);
|
|
const itemProfit = itemRevenue - itemCost;
|
|
const itemMargin = itemRevenue > 0 ? (itemProfit / itemRevenue) * 100 : 0;
|
|
|
|
return {
|
|
title: item.title,
|
|
quantity: item.quantity || 1,
|
|
price: item.price || 0,
|
|
printingCost: item.printingCost || 0,
|
|
itemRevenue,
|
|
itemCost,
|
|
itemProfit,
|
|
itemMargin
|
|
};
|
|
});
|
|
|
|
// Categorize profit performance
|
|
let profitCategory: OrderProfitAnalysis['profitCategory'];
|
|
if (margin > 50) profitCategory = 'excellent';
|
|
else if (margin > 30) profitCategory = 'good';
|
|
else if (margin > 15) profitCategory = 'average';
|
|
else if (margin > 0) profitCategory = 'poor';
|
|
else profitCategory = 'loss';
|
|
|
|
return {
|
|
orderId: order._id,
|
|
orderNumber: order.orderNumber,
|
|
date: order.dateOrdered,
|
|
customerName: order.customer?.name || 'Unknown Customer',
|
|
revenue,
|
|
printingCosts,
|
|
profit,
|
|
margin,
|
|
items: itemAnalyses,
|
|
profitCategory
|
|
};
|
|
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
}
|
|
|
|
/**
|
|
* Get orders by profit category
|
|
*/
|
|
static getOrdersByProfitCategory(orders: Order[], products: Product[]): {
|
|
excellent: OrderProfitAnalysis[];
|
|
good: OrderProfitAnalysis[];
|
|
average: OrderProfitAnalysis[];
|
|
poor: OrderProfitAnalysis[];
|
|
loss: OrderProfitAnalysis[];
|
|
} {
|
|
const analyses = this.analyzeOrderProfitability(orders, products);
|
|
|
|
return {
|
|
excellent: analyses.filter(a => a.profitCategory === 'excellent'),
|
|
good: analyses.filter(a => a.profitCategory === 'good'),
|
|
average: analyses.filter(a => a.profitCategory === 'average'),
|
|
poor: analyses.filter(a => a.profitCategory === 'poor'),
|
|
loss: analyses.filter(a => a.profitCategory === 'loss')
|
|
};
|
|
}
|
|
}
|
|
|
|
export default ProfitAnalysisService; |