- Complete UI refactor with charcoal/bone color palette - Add Space Grotesk font for headlines, Inter for body - Update all 24+ pages with new design system - Add LinearPrimitives reusable components - Improve dark mode support throughout - Add subtle micro-animations and transitions
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
DollarSign, TrendingUp, TrendingDown, PieChart,
|
|
Plus, ArrowUpRight, ArrowDownRight, Loader2
|
|
} from 'lucide-react';
|
|
import api from '../lib/api';
|
|
import { PageHeader, MetricCard, EmptyState, CardSkeleton } from '../components/ui/LinearPrimitives';
|
|
|
|
interface Transaction {
|
|
id: string;
|
|
type: 'EXPENSE' | 'REVENUE' | 'ADJUSTMENT';
|
|
category?: string;
|
|
amount: number;
|
|
description: string;
|
|
date: string;
|
|
}
|
|
|
|
interface ProfitLossData {
|
|
periods: { period: string; revenue: number; expenses: number; net: number }[];
|
|
totals: { revenue: number; expenses: number; net: number };
|
|
}
|
|
|
|
interface CategoryBreakdown {
|
|
breakdown: { category: string; amount: number; percentage: number }[];
|
|
total: number;
|
|
}
|
|
|
|
export default function FinancialDashboard() {
|
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
const [totals, setTotals] = useState<Record<string, number>>({});
|
|
const [profitLoss, setProfitLoss] = useState<ProfitLossData | null>(null);
|
|
const [categories, setCategories] = useState<CategoryBreakdown | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [dateRange, setDateRange] = useState({ start: '', end: '' });
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [dateRange]);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const params = {
|
|
startDate: dateRange.start || undefined,
|
|
endDate: dateRange.end || undefined
|
|
};
|
|
|
|
const [txRes, plRes, catRes] = await Promise.all([
|
|
api.get('/api/financial/transactions', { params: { ...params, limit: 20 } }),
|
|
api.get('/api/financial/reports/profit-loss', { params }),
|
|
api.get('/api/financial/reports/category-breakdown', { params })
|
|
]);
|
|
|
|
setTransactions(txRes.data.transactions);
|
|
setTotals(txRes.data.totals);
|
|
setProfitLoss(plRes.data);
|
|
setCategories(catRes.data);
|
|
} catch (error) {
|
|
console.error('Failed to load financial data:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const formatCurrency = (amount: number): string => {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD'
|
|
}).format(amount);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-6 animate-in">
|
|
<PageHeader title="Financial Overview" subtitle="Loading..." />
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
{Array.from({ length: 3 }).map((_, i) => <CardSkeleton key={i} />)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const netAmount = (totals.REVENUE || 0) - (totals.EXPENSE || 0);
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 animate-in">
|
|
<PageHeader
|
|
title="Financial Overview"
|
|
subtitle="Track revenue, expenses, and profitability"
|
|
actions={
|
|
<button
|
|
onClick={() => setShowAddModal(true)}
|
|
className="btn btn-primary"
|
|
>
|
|
<Plus size={16} />
|
|
<span className="hidden sm:inline">Add Transaction</span>
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<MetricCard
|
|
icon={ArrowUpRight}
|
|
label="Revenue"
|
|
value={formatCurrency(totals.REVENUE || 0)}
|
|
accent="success"
|
|
/>
|
|
<MetricCard
|
|
icon={ArrowDownRight}
|
|
label="Expenses"
|
|
value={formatCurrency(totals.EXPENSE || 0)}
|
|
accent="destructive"
|
|
/>
|
|
<div className={`
|
|
card p-4
|
|
${netAmount >= 0 ? 'border-success/30 bg-success-muted' : 'border-destructive/30 bg-destructive-muted'}
|
|
`}>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-xs text-tertiary uppercase tracking-wider">Net Profit/Loss</span>
|
|
{netAmount >= 0 ? (
|
|
<TrendingUp className="text-success" size={16} />
|
|
) : (
|
|
<TrendingDown className="text-destructive" size={16} />
|
|
)}
|
|
</div>
|
|
<div className={`text-2xl font-semibold ${netAmount >= 0 ? 'text-success' : 'text-destructive'}`}>
|
|
{formatCurrency(netAmount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Grid */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* Expense Breakdown */}
|
|
<div className="card p-5">
|
|
<h2 className="text-sm font-medium text-primary mb-4 flex items-center gap-2">
|
|
<PieChart size={16} className="text-accent" />
|
|
Expense Breakdown
|
|
</h2>
|
|
|
|
<div className="space-y-3">
|
|
{categories?.breakdown.map(cat => (
|
|
<div key={cat.category}>
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="text-secondary capitalize">{cat.category.toLowerCase()}</span>
|
|
<span className="font-medium text-primary">{formatCurrency(cat.amount)}</span>
|
|
</div>
|
|
<div className="h-1.5 bg-tertiary rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all duration-normal"
|
|
style={{ width: `${cat.percentage}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-tertiary mt-0.5">
|
|
{cat.percentage.toFixed(1)}% of total
|
|
</div>
|
|
</div>
|
|
))}
|
|
{(!categories || categories.breakdown.length === 0) && (
|
|
<p className="text-tertiary text-center py-4 text-sm">No expense data</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Monthly Trend */}
|
|
<div className="card p-5">
|
|
<h2 className="text-sm font-medium text-primary mb-4 flex items-center gap-2">
|
|
<TrendingUp size={16} className="text-accent" />
|
|
Monthly Trend
|
|
</h2>
|
|
|
|
<div className="space-y-3">
|
|
{profitLoss?.periods.slice(-6).map(period => (
|
|
<div key={period.period} className="flex items-center gap-3">
|
|
<span className="w-16 text-xs text-tertiary">{period.period}</span>
|
|
<div className="flex-1 flex items-center gap-1">
|
|
<div
|
|
className="h-3 bg-success rounded-sm"
|
|
style={{
|
|
width: `${Math.min((period.revenue / (profitLoss.totals.revenue || 1)) * 100, 100)}%`
|
|
}}
|
|
/>
|
|
<div
|
|
className="h-3 bg-destructive rounded-sm"
|
|
style={{
|
|
width: `${Math.min((period.expenses / (profitLoss.totals.expenses || 1)) * 100, 100)}%`
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className={`text-xs font-medium w-20 text-right ${period.net >= 0 ? 'text-success' : 'text-destructive'}`}>
|
|
{formatCurrency(period.net)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{(!profitLoss || profitLoss.periods.length === 0) && (
|
|
<p className="text-tertiary text-center py-4 text-sm">No trend data</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-4 mt-4 text-[10px]">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-success rounded-sm" />
|
|
<span className="text-tertiary">Revenue</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-2 h-2 bg-destructive rounded-sm" />
|
|
<span className="text-tertiary">Expenses</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Transactions */}
|
|
<div className="card overflow-hidden">
|
|
<div className="p-4 border-b border-subtle flex items-center justify-between">
|
|
<h2 className="text-sm font-medium text-primary">Recent Transactions</h2>
|
|
<button className="text-accent hover:underline text-xs">View All</button>
|
|
</div>
|
|
|
|
<div className="divide-y divide-subtle">
|
|
{transactions.map(tx => (
|
|
<div key={tx.id} className="p-4 flex items-center gap-3">
|
|
<div className={`
|
|
w-8 h-8 rounded-md flex items-center justify-center
|
|
${tx.type === 'REVENUE' ? 'bg-success-muted text-success' :
|
|
tx.type === 'EXPENSE' ? 'bg-destructive-muted text-destructive' :
|
|
'bg-tertiary text-secondary'}
|
|
`}>
|
|
<DollarSign size={14} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-primary text-sm truncate">{tx.description}</div>
|
|
<div className="text-[10px] text-tertiary flex items-center gap-2">
|
|
{tx.category && <span className="capitalize">{tx.category.toLowerCase()}</span>}
|
|
<span>·</span>
|
|
<span>{new Date(tx.date).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
<div className={`
|
|
text-right font-semibold text-sm
|
|
${tx.type === 'REVENUE' ? 'text-success' :
|
|
tx.type === 'EXPENSE' ? 'text-destructive' :
|
|
'text-secondary'}
|
|
`}>
|
|
{tx.type === 'EXPENSE' ? '-' : '+'}{formatCurrency(tx.amount)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{transactions.length === 0 && (
|
|
<EmptyState
|
|
icon={DollarSign}
|
|
title="No transactions"
|
|
description="Add your first transaction to get started."
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|