etsy-finance-tracker/client/src/pages/Expenses.tsx
dlawler489 9e1a098a70 Initial commit: Complete Etsy Business Tracker with Profit Analysis Dashboard
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
2026-04-20 09:44:54 +10:00

666 lines
No EOL
26 KiB
TypeScript

import React, { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addExpenses, addExpense, updateExpense, deleteExpense } from '../store/slices/expenseSlice';
import { formatAustralianDate } from '../utils/dateFormatter';
import { Upload, Plus, Search, Edit, Trash2, Receipt, Download, DollarSign } from 'lucide-react';
import toast from 'react-hot-toast';
interface ExpenseFormData {
description: string;
amount: number;
category: string;
date: string;
taxDeductible: boolean;
vendor: string;
receiptUrl?: string;
}
const Expenses = () => {
const dispatch = useDispatch();
const { expenses } = useSelector((state: RootState) => state.expenses);
const [showAddForm, setShowAddForm] = useState(false);
const [editingExpense, setEditingExpense] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [dateRange, setDateRange] = useState('all');
const [showTaxDeductibleOnly, setShowTaxDeductibleOnly] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const etsyFileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<ExpenseFormData>({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
const expenseCategories = [
'Shipping & Postage',
'Materials & Supplies',
'Listing Fees',
'Transaction Fees',
'Payment Processing Fees',
'Marketing & Advertising',
'Taxes & GST',
'Packaging',
'Office Supplies',
'Professional Services',
'Software & Subscriptions',
'Travel & Transport',
'Other'
];
const handleAusPostCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.endsWith('.csv')) {
toast.error('Please select a valid CSV file');
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
const text = e.target?.result as string;
const lines = text.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, '').toLowerCase());
toast.loading('Importing expenses from CSV...');
const importedExpenses = [];
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '') continue;
const values = lines[i].split(',').map(v => v.trim().replace(/"/g, ''));
const expenseData: any = {};
headers.forEach((header, index) => {
const value = values[index] || '';
switch (header) {
case 'transaction date':
case 'date':
case 'posted date':
// Convert DD/MM/YYYY to YYYY-MM-DD format
if (value.includes('/')) {
const [day, month, year] = value.split('/');
expenseData.date = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
} else {
expenseData.date = value;
}
break;
case 'total cost':
case 'amount':
case 'cost':
case 'charge':
const amount = parseFloat(value.replace(/[$,AUD\s-]/g, ''));
expenseData.amount = Math.abs(amount) || 0; // Make positive
break;
case 'tracking number':
case 'transaction id':
case 'reference':
case 'receipt':
expenseData.reference = value;
break;
case 'description':
case 'merchant':
case 'vendor':
expenseData.description = value;
expenseData.vendor = value;
break;
case 'category':
case 'type':
expenseData.category = value || 'Shipping & Postage';
break;
}
});
// Auto-categorize based on description or set default for Australia Post
if (!expenseData.category) {
expenseData.category = 'Shipping & Postage';
}
// If no description, create one from the tracking number or transaction ID
if (!expenseData.description) {
expenseData.description = expenseData.reference ?
`Australia Post - ${expenseData.reference}` :
'Australia Post Shipping';
}
if (!expenseData.vendor) {
expenseData.vendor = 'Australia Post';
}
if (expenseData.amount > 0) {
importedExpenses.push({
_id: `temp-${Date.now()}-${i}`,
description: expenseData.description || 'Imported Expense',
amount: expenseData.amount,
category: expenseData.category || 'Other',
date: expenseData.date || new Date().toISOString().split('T')[0],
taxDeductible: true, // Assume business expenses are tax deductible
vendor: expenseData.vendor || expenseData.description || 'Unknown',
reference: expenseData.reference
});
}
}
// Simulate API call - replace with actual API call
console.log('Imported expenses:', importedExpenses);
// Add the imported expenses to the store
dispatch(addExpenses(importedExpenses));
toast.success(`Successfully imported ${importedExpenses.length} expenses`);
} catch (error) {
console.error('Import error:', error);
toast.error('Failed to import CSV file. Please check the format.');
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleEtsyCSVImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
toast.error('Please use the Data Import page for Etsy CSV files. This ensures proper separation of sales and expenses.');
// Clear the input
if (etsyFileInputRef.current) {
etsyFileInputRef.current.value = '';
}
// Optionally redirect to Data Import page
setTimeout(() => {
window.location.href = '/data-import';
}, 2000);
};
const handleExportCSV = () => {
const headers = [
'Date', 'Description', 'Category', 'Amount', 'Vendor',
'Tax Deductible', 'Reference'
];
const csvData = expenses.map(expense => [
expense.date,
expense.description,
expense.category,
expense.amount.toFixed(2),
expense.vendor || '',
expense.taxDeductible ? 'Yes' : 'No',
expense.reference || ''
]);
const csv = [headers, ...csvData].map(row =>
row.map(field => `"${field}"`).join(',')
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `business-expenses-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
toast.success('Expenses exported successfully');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingExpense) {
// Update existing expense
dispatch(updateExpense({
_id: editingExpense,
...formData
}));
toast.success('Expense updated successfully!');
setEditingExpense(null);
} else {
// Add new expense
dispatch(addExpense({
_id: `expense-${Date.now()}`,
...formData
}));
toast.success('Expense added successfully!');
}
setShowAddForm(false);
setFormData({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
} catch (error) {
toast.error('Failed to save expense');
}
};
const handleEdit = (expense: any) => {
setFormData({
description: expense.description,
amount: expense.amount,
category: expense.category,
date: expense.date,
taxDeductible: expense.taxDeductible,
vendor: expense.vendor || ''
});
setEditingExpense(expense._id);
setShowAddForm(true);
};
const handleDelete = (expenseId: string) => {
if (window.confirm('Are you sure you want to delete this expense?')) {
dispatch(deleteExpense(expenseId));
toast.success('Expense deleted successfully!');
}
};
const handleCancel = () => {
setShowAddForm(false);
setEditingExpense(null);
setFormData({
description: '',
amount: 0,
category: '',
date: new Date().toISOString().split('T')[0],
taxDeductible: false,
vendor: ''
});
};
const filteredExpenses = expenses
.filter(expense => {
const matchesSearch = expense.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
expense.category.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = categoryFilter === 'all' || expense.category === categoryFilter;
const matchesTaxDeductible = !showTaxDeductibleOnly || expense.taxDeductible;
let matchesDate = true;
if (dateRange !== 'all') {
const expenseDate = new Date(expense.date);
const now = new Date();
switch (dateRange) {
case 'week':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 7 * 24 * 60 * 60 * 1000;
break;
case 'month':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 30 * 24 * 60 * 60 * 1000;
break;
case 'quarter':
matchesDate = (now.getTime() - expenseDate.getTime()) <= 90 * 24 * 60 * 60 * 1000;
break;
case 'year':
matchesDate = expenseDate.getFullYear() === now.getFullYear();
break;
}
}
return matchesSearch && matchesCategory && matchesTaxDeductible && matchesDate;
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Sort by newest first
const totalExpenses = filteredExpenses.reduce((sum, expense) => sum + expense.amount, 0);
const taxDeductibleTotal = filteredExpenses.filter(e => e.taxDeductible).reduce((sum, expense) => sum + expense.amount, 0);
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">Expenses</h1>
<p className="text-gray-600 mt-1">Track business expenses and tax deductions</p>
</div>
<div className="flex gap-3">
<input
type="file"
ref={fileInputRef}
onChange={handleAusPostCSVImport}
accept=".csv"
style={{ display: 'none' }}
/>
<input
type="file"
ref={etsyFileInputRef}
onChange={handleEtsyCSVImport}
accept=".csv"
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Upload className="w-4 h-4" />
Import Australia Post CSV
</button>
<button
onClick={() => etsyFileInputRef.current?.click()}
className="flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
<Upload className="w-4 h-4" />
Import Etsy CSV
</button>
<button
onClick={handleExportCSV}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<Download className="w-4 h-4" />
Export CSV
</button>
<button
onClick={() => setShowAddForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Add Expense
</button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<DollarSign className="h-8 w-8 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Total Expenses</p>
<p className="text-2xl font-semibold text-gray-900">${totalExpenses.toFixed(2)}</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Receipt className="h-8 w-8 text-green-500" />
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-500">Tax Deductible</p>
<p className="text-2xl font-semibold text-gray-900">${taxDeductibleTotal.toFixed(2)}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="flex gap-4 mb-6 flex-wrap">
<div className="relative flex-1 min-w-64">
<Search className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
<input
type="text"
placeholder="Search expenses..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
>
<option value="all">All Categories</option>
{expenseCategories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
<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)}
>
<option value="all">All Time</option>
<option value="week">Last Week</option>
<option value="month">Last Month</option>
<option value="quarter">Last Quarter</option>
<option value="year">This Year</option>
</select>
<label className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg">
<input
type="checkbox"
checked={showTaxDeductibleOnly}
onChange={(e) => setShowTaxDeductibleOnly(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Tax Deductible Only</span>
</label>
</div>
{/* Add Expense Form Modal */}
{showAddForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingExpense ? 'Edit Expense' : 'Add New Expense'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount ($)</label>
<input
type="number"
step="0.01"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: parseFloat(e.target.value) || 0})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.category}
onChange={(e) => setFormData({...formData, category: e.target.value})}
>
<option value="">Select Category</option>
{expenseCategories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Date</label>
<input
type="date"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vendor</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={formData.vendor}
onChange={(e) => setFormData({...formData, vendor: e.target.value})}
/>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="taxDeductible"
checked={formData.taxDeductible}
onChange={(e) => setFormData({...formData, taxDeductible: e.target.checked})}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="taxDeductible" className="text-sm text-gray-700">
This expense is tax deductible
</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingExpense ? 'Update Expense' : 'Add Expense'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Expenses List */}
<div className="grid gap-6">
{filteredExpenses.length === 0 ? (
<div className="text-center py-12">
<Receipt className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No expenses found</h3>
<p className="text-gray-500 mb-4">Import your expense data or add expenses manually.</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Upload className="w-4 h-4" />
Import Australia Post CSV
</button>
<button
onClick={() => etsyFileInputRef.current?.click()}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
<Upload className="w-4 h-4" />
Import Etsy CSV
</button>
<button
onClick={() => setShowAddForm(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<Plus className="w-4 h-4" />
Add Expense
</button>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="flex items-center gap-1">
Date
<span className="text-xs text-gray-400">( newest first)</span>
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Tax Deductible
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredExpenses.map((expense) => (
<tr key={expense._id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatAustralianDate(expense.date)}
</td>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900">{expense.description}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{expense.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${expense.amount.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{expense.taxDeductible ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Yes
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
No
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(expense)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(expense._id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
};
export default Expenses;