ca-grow-ops-manager/frontend/src/pages/FinancialDashboard.tsx
fullsizemalt 71e58dd4c7
Some checks are pending
Deploy to Production / deploy (push) Waiting to run
Test / backend-test (push) Waiting to run
Test / frontend-test (push) Waiting to run
feat: Linear-inspired UI redesign with Space Grotesk headlines
- 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
2025-12-12 14:29:47 -08:00

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>
);
}