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
666 lines
No EOL
26 KiB
TypeScript
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; |