- Badges: Estilo translúcido uniforme (bg-opacity-25 + text-color) via CSS global - Afetados: RecurringTransactions, Accounts, Categories, TransactionsByWeek - Widgets: UpcomingWidget, OverdueWidget, CalendarWidget, OverpaymentsAnalysis - Botões: Estilo outline padronizado (btn-outline-*) em RecurringTransactions - Simplificação: Remover classes redundantes dos JSX
1129 lines
50 KiB
JavaScript
1129 lines
50 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { financialHealthService } from '../services/api';
|
|
import useFormatters from '../hooks/useFormatters';
|
|
import {
|
|
Chart as ChartJS,
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
ArcElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
} from 'chart.js';
|
|
import { Line, Doughnut, Bar } from 'react-chartjs-2';
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
BarElement,
|
|
ArcElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
);
|
|
|
|
const FinancialHealth = () => {
|
|
const { t } = useTranslation();
|
|
const { currency: formatCurrency, percent, number } = useFormatters();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [data, setData] = useState(null);
|
|
const [history, setHistory] = useState([]);
|
|
const [activeTab, setActiveTab] = useState('overview');
|
|
|
|
// Helper para formatear moneda usando la moneda del API
|
|
const currency = (value, currencyCode = null) => {
|
|
const code = currencyCode || data?.currency || 'EUR';
|
|
return formatCurrency(value, code);
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [healthData, historyData] = await Promise.all([
|
|
financialHealthService.get(),
|
|
financialHealthService.getHistory({ months: 6 })
|
|
]);
|
|
setData(healthData);
|
|
// Asegurar que history sea siempre un array
|
|
setHistory(Array.isArray(historyData) ? historyData : []);
|
|
} catch (error) {
|
|
console.error('Error loading financial health:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getScoreColor = (score) => {
|
|
if (score >= 80) return '#10b981';
|
|
if (score >= 60) return '#84cc16';
|
|
if (score >= 40) return '#f59e0b';
|
|
if (score >= 20) return '#f97316';
|
|
return '#ef4444';
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
const colors = {
|
|
excellent: '#10b981',
|
|
good: '#84cc16',
|
|
adequate: '#22c55e',
|
|
moderate: '#f59e0b',
|
|
needs_improvement: '#f97316',
|
|
needs_attention: '#f97316',
|
|
needs_work: '#f97316',
|
|
negative: '#ef4444',
|
|
critical: '#ef4444',
|
|
insufficient: '#ef4444',
|
|
debt_free: '#10b981',
|
|
healthy: '#22c55e',
|
|
manageable: '#f59e0b',
|
|
concerning: '#ef4444',
|
|
on_track: '#10b981',
|
|
exceeded: '#ef4444',
|
|
not_configured: '#6b7280',
|
|
very_stable: '#10b981',
|
|
stable: '#22c55e',
|
|
volatile: '#ef4444',
|
|
optimized: '#10b981',
|
|
acceptable: '#f59e0b',
|
|
high_discretionary: '#f97316',
|
|
};
|
|
return colors[status] || '#6b7280';
|
|
};
|
|
|
|
const metricConfigs = {
|
|
savings_capacity: {
|
|
icon: 'bi-piggy-bank-fill',
|
|
gradient: ['#059669', '#10b981'],
|
|
},
|
|
debt_control: {
|
|
icon: 'bi-credit-card-2-front-fill',
|
|
gradient: ['#2563eb', '#3b82f6'],
|
|
},
|
|
budget_management: {
|
|
icon: 'bi-wallet2',
|
|
gradient: ['#7c3aed', '#8b5cf6'],
|
|
},
|
|
expense_efficiency: {
|
|
icon: 'bi-pie-chart-fill',
|
|
gradient: ['#0891b2', '#06b6d4'],
|
|
},
|
|
emergency_fund: {
|
|
icon: 'bi-shield-fill-check',
|
|
gradient: ['#d97706', '#f59e0b'],
|
|
},
|
|
financial_stability: {
|
|
icon: 'bi-graph-up-arrow',
|
|
gradient: ['#db2777', '#ec4899'],
|
|
},
|
|
};
|
|
|
|
const tabs = [
|
|
{ id: 'overview', icon: 'bi-speedometer2', label: t('financialHealth.tabs.overview') },
|
|
{ id: 'metrics', icon: 'bi-bar-chart-fill', label: t('financialHealth.tabs.metrics') },
|
|
{ id: 'categories', icon: 'bi-pie-chart-fill', label: t('financialHealth.tabs.categories') },
|
|
{ id: 'trends', icon: 'bi-graph-up', label: t('financialHealth.tabs.trends') },
|
|
{ id: 'insights', icon: 'bi-lightbulb-fill', label: t('financialHealth.tabs.insights') },
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
|
<div className="spinner-border text-primary" role="status">
|
|
<span className="visually-hidden">{t('common.loading')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="alert alert-warning">
|
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
|
{t('financialHealth.errorLoading')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const score = data.overall_score;
|
|
const scoreColor = getScoreColor(score);
|
|
|
|
// Asegurar arrays válidos para Chart.js
|
|
const safeHistory = Array.isArray(history) ? history : [];
|
|
const safeMonthlyData = Array.isArray(data.trends?.monthly_data) ? data.trends.monthly_data : [];
|
|
const safeTopExpenses = Array.isArray(data.category_analysis?.top_expenses) ? data.category_analysis.top_expenses : [];
|
|
const safeCategoryTrends = Array.isArray(data.category_analysis?.category_trends) ? data.category_analysis.category_trends : [];
|
|
|
|
// Chart data for history
|
|
const historyChartData = safeHistory.length > 0 ? {
|
|
labels: safeHistory.map(h => h.month_label),
|
|
datasets: [{
|
|
label: t('financialHealth.score'),
|
|
data: safeHistory.map(h => h.score),
|
|
borderColor: scoreColor,
|
|
backgroundColor: `${scoreColor}20`,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 4,
|
|
pointBackgroundColor: scoreColor,
|
|
}],
|
|
} : null;
|
|
|
|
// Chart data for expense distribution
|
|
const distributionChartData = data.category_analysis?.expense_distribution ? {
|
|
labels: [
|
|
t('financialHealth.distribution.fixed'),
|
|
t('financialHealth.distribution.variable'),
|
|
t('financialHealth.distribution.discretionary'),
|
|
],
|
|
datasets: [{
|
|
data: [
|
|
data.category_analysis.expense_distribution.fixed.percentage,
|
|
data.category_analysis.expense_distribution.variable.percentage,
|
|
data.category_analysis.expense_distribution.discretionary.percentage,
|
|
],
|
|
backgroundColor: ['#3b82f6', '#22c55e', '#f59e0b'],
|
|
borderWidth: 0,
|
|
}],
|
|
} : null;
|
|
|
|
// Chart data for monthly comparison
|
|
const monthlyChartData = safeMonthlyData.length > 0 ? {
|
|
labels: safeMonthlyData.map(m => m.month),
|
|
datasets: [
|
|
{
|
|
label: t('financialHealth.income'),
|
|
data: safeMonthlyData.map(m => m.income),
|
|
backgroundColor: '#22c55e',
|
|
borderRadius: 4,
|
|
},
|
|
{
|
|
label: t('financialHealth.expenses'),
|
|
data: safeMonthlyData.map(m => m.expenses),
|
|
backgroundColor: '#ef4444',
|
|
borderRadius: 4,
|
|
},
|
|
],
|
|
} : null;
|
|
|
|
// Category expenses chart
|
|
const categoryChartData = safeTopExpenses.length > 0 ? {
|
|
labels: safeTopExpenses.slice(0, 8).map(c => c.name),
|
|
datasets: [{
|
|
data: safeTopExpenses.slice(0, 8).map(c => c.total),
|
|
backgroundColor: safeTopExpenses.slice(0, 8).map(c => c.color || '#6b7280'),
|
|
borderWidth: 0,
|
|
}],
|
|
} : null;
|
|
|
|
return (
|
|
<div className="financial-health-container">
|
|
{/* Header */}
|
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h4 className="text-white mb-1 fw-bold">
|
|
<i className="bi bi-heart-pulse me-2"></i>
|
|
{t('financialHealth.title')}
|
|
</h4>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{t('financialHealth.subtitle')} • {t('financialHealth.lastUpdate')}: {new Date(data.last_updated).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<span className="badge bg-primary">{data.currency}</span>
|
|
<button className="btn btn-outline-primary btn-sm" onClick={loadData}>
|
|
<i className="bi bi-arrow-clockwise me-1"></i>
|
|
{t('common.refresh')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<ul className="nav nav-pills mb-4 gap-2">
|
|
{tabs.map(tab => (
|
|
<li key={tab.id} className="nav-item">
|
|
<button
|
|
className={`nav-link ${activeTab === tab.id ? 'active' : ''}`}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
style={{
|
|
background: activeTab === tab.id ? '#3b82f6' : '#1e293b',
|
|
color: activeTab === tab.id ? 'white' : '#94a3b8',
|
|
border: 'none',
|
|
}}
|
|
>
|
|
<i className={`bi ${tab.icon} me-2`}></i>
|
|
{tab.label}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{/* Overview Tab */}
|
|
{activeTab === 'overview' && (
|
|
<div className="row g-4">
|
|
{/* Score Circle */}
|
|
<div className="col-lg-4">
|
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
|
<div className="card-body text-center py-4">
|
|
{/* Score Ring */}
|
|
<div className="position-relative d-inline-block mb-3">
|
|
<svg width="180" height="180" viewBox="0 0 180 180">
|
|
<circle cx="90" cy="90" r="75" fill="none" stroke="#1e293b" strokeWidth="12" />
|
|
<circle
|
|
cx="90" cy="90" r="75" fill="none"
|
|
stroke={scoreColor}
|
|
strokeWidth="12"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${(score / 100) * 471} 471`}
|
|
transform="rotate(-90 90 90)"
|
|
style={{ filter: `drop-shadow(0 0 8px ${scoreColor}50)`, transition: 'stroke-dasharray 1s ease-in-out' }}
|
|
/>
|
|
<text x="90" y="80" textAnchor="middle" fill={scoreColor} fontSize="42" fontWeight="bold">{score}</text>
|
|
<text x="90" y="105" textAnchor="middle" fill="#94a3b8" fontSize="14">{t('financialHealth.outOf100')}</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<h5 className="mb-2" style={{ color: data.health_level?.color }}>
|
|
{t(`financialHealth.levels.${data.health_level?.level}`)}
|
|
</h5>
|
|
<p className="text-slate-400 small mb-3">{t('financialHealth.scoreDescription')}</p>
|
|
|
|
{/* Mini History Chart */}
|
|
{historyChartData && (
|
|
<div style={{ height: '80px' }}>
|
|
<Line
|
|
data={historyChartData}
|
|
options={{
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, min: 0, max: 100 },
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="col-lg-8">
|
|
<div className="row g-3">
|
|
{/* Net Worth */}
|
|
<div className="col-md-6">
|
|
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<small className="text-slate-400">{t('financialHealth.summary.netWorth')}</small>
|
|
<h4 className={`mb-0 fw-bold ${data.summary?.net_worth >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(data.summary?.net_worth || 0)}
|
|
</h4>
|
|
</div>
|
|
<i className="bi bi-bank fs-3 text-primary opacity-50"></i>
|
|
</div>
|
|
<div className="mt-2 d-flex gap-3 small">
|
|
<span className="text-success">
|
|
<i className="bi bi-arrow-up me-1"></i>
|
|
{t('financialHealth.summary.assets')}: {currency(data.summary?.total_assets || 0)}
|
|
</span>
|
|
<span className="text-danger">
|
|
<i className="bi bi-arrow-down me-1"></i>
|
|
{t('financialHealth.summary.liabilities')}: {currency(data.summary?.total_liabilities || 0)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Monthly Savings */}
|
|
<div className="col-md-6">
|
|
<div className="card border-0 h-100" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<small className="text-slate-400">{t('financialHealth.summary.monthlySavings')}</small>
|
|
<h4 className={`mb-0 fw-bold ${data.summary?.monthly_savings >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(data.summary?.monthly_savings || 0)}
|
|
</h4>
|
|
</div>
|
|
<i className="bi bi-piggy-bank fs-3 text-success opacity-50"></i>
|
|
</div>
|
|
<div className="mt-2 small">
|
|
<span className="text-slate-400">
|
|
{t('financialHealth.summary.savingsRate')}:
|
|
<span className={data.summary?.savings_rate >= 20 ? 'text-success' : 'text-warning'}>
|
|
{' '}{data.summary?.savings_rate || 0}%
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Monthly Income */}
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.summary.monthlyIncome')}</small>
|
|
<h5 className="text-success mb-0">{currency(data.summary?.monthly_income || 0)}</h5>
|
|
{data.trends?.monthly_comparison?.income?.change !== 0 && (
|
|
<small className={data.trends.monthly_comparison.income.change > 0 ? 'text-success' : 'text-danger'}>
|
|
<i className={`bi bi-arrow-${data.trends.monthly_comparison.income.change > 0 ? 'up' : 'down'} me-1`}></i>
|
|
{Math.abs(data.trends.monthly_comparison.income.change)}% {t('financialHealth.vsLastMonth')}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Monthly Expenses */}
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.summary.monthlyExpenses')}</small>
|
|
<h5 className="text-danger mb-0">{currency(data.summary?.monthly_expenses || 0)}</h5>
|
|
{data.trends?.monthly_comparison?.expenses?.change !== 0 && (
|
|
<small className={data.trends.monthly_comparison.expenses.change < 0 ? 'text-success' : 'text-danger'}>
|
|
<i className={`bi bi-arrow-${data.trends.monthly_comparison.expenses.change > 0 ? 'up' : 'down'} me-1`}></i>
|
|
{Math.abs(data.trends.monthly_comparison.expenses.change)}% {t('financialHealth.vsLastMonth')}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projection */}
|
|
<div className="col-md-4">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.summary.projectedSavings')}</small>
|
|
<h5 className={`mb-0 ${data.projection?.projected?.savings >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(data.projection?.projected?.savings || 0)}
|
|
</h5>
|
|
<small className="text-slate-500">
|
|
{data.projection?.days_remaining} {t('financialHealth.daysRemaining')}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Accounts by Currency */}
|
|
{data.summary?.accounts_by_currency?.length > 1 && (
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block mb-2">
|
|
<i className="bi bi-currency-exchange me-1"></i>
|
|
{t('financialHealth.summary.byCurrency')}
|
|
</small>
|
|
<div className="d-flex flex-wrap gap-3">
|
|
{data.summary.accounts_by_currency.map((curr, idx) => (
|
|
<div key={idx} className="d-flex align-items-center">
|
|
<span className="badge bg-primary me-2">{curr.currency}</span>
|
|
<span className={curr.balance >= 0 ? 'text-success' : 'text-danger'}>
|
|
{currency(curr.balance, curr.currency)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Metrics Tab */}
|
|
{activeTab === 'metrics' && (
|
|
<div className="row g-3">
|
|
{Object.entries(data.metrics || {}).map(([key, metric]) => {
|
|
const config = metricConfigs[key];
|
|
if (!config) return null;
|
|
|
|
return (
|
|
<div key={key} className="col-md-6 col-lg-4">
|
|
<div
|
|
className="card border-0 h-100"
|
|
style={{
|
|
background: `linear-gradient(135deg, ${config.gradient[0]} 0%, ${config.gradient[1]} 100%)`,
|
|
}}
|
|
>
|
|
<div className="card-body text-white">
|
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
|
<div className="d-flex align-items-center">
|
|
<i className={`bi ${config.icon} fs-4 me-2 opacity-75`}></i>
|
|
<span className="fw-medium">{t(`financialHealth.metrics.${key}`)}</span>
|
|
</div>
|
|
<span
|
|
className="badge"
|
|
style={{
|
|
background: 'rgba(255,255,255,0.2)',
|
|
color: 'white',
|
|
}}
|
|
>
|
|
{t(`financialHealth.status.${metric.status}`)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="d-flex align-items-end mb-3">
|
|
<h2 className="mb-0 fw-bold">{metric.score}</h2>
|
|
<span className="ms-2 opacity-75">/100</span>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="progress bg-white bg-opacity-25 mb-3" style={{ height: '6px' }}>
|
|
<div className="progress-bar bg-white" style={{ width: `${metric.score}%` }}></div>
|
|
</div>
|
|
|
|
{/* Metric-specific details */}
|
|
<div className="small opacity-90">
|
|
{key === 'savings_capacity' && (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.savingsRate')}:</span>
|
|
<span>{metric.savings_rate}%</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.monthlySavings')}:</span>
|
|
<span>{currency(metric.monthly_savings)}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
{key === 'debt_control' && (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.totalDebt')}:</span>
|
|
<span>{currency(metric.total_debt)}</span>
|
|
</div>
|
|
{/* Multi-currency breakdown */}
|
|
{metric.debt_by_currency && Object.keys(metric.debt_by_currency).length > 1 && (
|
|
<div className="d-flex gap-1 flex-wrap mb-2">
|
|
{Object.entries(metric.debt_by_currency).map(([curr, amt]) => (
|
|
<span key={curr} className="badge bg-danger" style={{ fontSize: '0.65rem' }}>
|
|
{curr}: {Number(amt).toLocaleString(undefined, {minimumFractionDigits: 2})}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.debtToIncome')}:</span>
|
|
<span>{metric.debt_to_income_ratio}%</span>
|
|
</div>
|
|
{metric.active_debts > 0 && (
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.activeDebts')}:</span>
|
|
<span>{metric.active_debts}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{key === 'budget_management' && (
|
|
<>
|
|
{metric.has_budgets ? (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.budgetsConfigured')}:</span>
|
|
<span>{metric.total_budgets}</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.compliance')}:</span>
|
|
<span>{metric.compliance_rate}%</span>
|
|
</div>
|
|
{metric.exceeded_count > 0 && (
|
|
<div className="d-flex justify-content-between text-warning">
|
|
<span>{t('financialHealth.details.exceeded')}:</span>
|
|
<span>{metric.exceeded_count}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="opacity-75">{t('financialHealth.details.noBudgets')}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
{key === 'expense_efficiency' && (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.distribution.fixed')}:</span>
|
|
<span>{metric.distribution?.fixed?.percentage}%</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.distribution.variable')}:</span>
|
|
<span>{metric.distribution?.variable?.percentage}%</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.distribution.discretionary')}:</span>
|
|
<span>{metric.distribution?.discretionary?.percentage}%</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
{key === 'emergency_fund' && (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.liquidAssets')}:</span>
|
|
<span>{currency(metric.liquid_assets)}</span>
|
|
</div>
|
|
{/* Multi-currency breakdown */}
|
|
{metric.liquid_assets_by_currency && Object.keys(metric.liquid_assets_by_currency).length > 1 && (
|
|
<div className="d-flex gap-1 flex-wrap mb-2">
|
|
{Object.entries(metric.liquid_assets_by_currency).map(([curr, amt]) => (
|
|
<span key={curr} className="badge bg-primary" style={{ fontSize: '0.65rem' }}>
|
|
{curr}: {Number(amt).toLocaleString(undefined, {minimumFractionDigits: 2})}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.monthsCovered')}:</span>
|
|
<span>{metric.months_covered} {t('common.months')}</span>
|
|
</div>
|
|
{metric.gap > 0 && (
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.gap')}:</span>
|
|
<span>{currency(metric.gap)}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{key === 'financial_stability' && (
|
|
<>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.incomeVolatility')}:</span>
|
|
<span>{metric.income_volatility}%</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.expenseVolatility')}:</span>
|
|
<span>{metric.expense_volatility}%</span>
|
|
</div>
|
|
<div className="d-flex justify-content-between">
|
|
<span>{t('financialHealth.details.savingsTrend')}:</span>
|
|
<span>
|
|
<i className={`bi bi-arrow-${metric.savings_trend === 'increasing' ? 'up text-success' : metric.savings_trend === 'decreasing' ? 'down text-danger' : 'right text-warning'}`}></i>
|
|
{' '}{t(`financialHealth.trend.${metric.savings_trend}`)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Categories Tab */}
|
|
{activeTab === 'categories' && (
|
|
<div className="row g-4">
|
|
{/* Expense Distribution */}
|
|
<div className="col-lg-4">
|
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-pie-chart me-2"></i>
|
|
{t('financialHealth.categories.distribution')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
{distributionChartData && (
|
|
<div style={{ height: '200px' }}>
|
|
<Doughnut
|
|
data={distributionChartData}
|
|
options={{
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: { color: '#94a3b8' },
|
|
},
|
|
},
|
|
cutout: '60%',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="mt-3">
|
|
{data.category_analysis?.expense_distribution && Object.entries(data.category_analysis.expense_distribution).map(([key, val]) => (
|
|
<div key={key} className="mb-2">
|
|
<div className="d-flex justify-content-between small text-slate-400">
|
|
<span>{t(`financialHealth.distribution.${key}`)}</span>
|
|
<span>{currency(val.amount)} ({val.percentage}%)</span>
|
|
</div>
|
|
{/* Multi-currency breakdown */}
|
|
{val.by_currency && Object.keys(val.by_currency).length > 1 && (
|
|
<div className="d-flex gap-1 mt-1 flex-wrap">
|
|
{Object.entries(val.by_currency).map(([curr, amt]) => (
|
|
<span key={curr} className="badge bg-secondary" style={{ fontSize: '0.6rem' }}>
|
|
{curr}: {Number(amt).toLocaleString()}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Expenses */}
|
|
<div className="col-lg-8">
|
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-bar-chart me-2"></i>
|
|
{t('financialHealth.categories.topExpenses')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="row g-2">
|
|
{data.category_analysis?.top_expenses?.slice(0, 10).map((cat, idx) => (
|
|
<div key={idx} className="col-12">
|
|
<div className="d-flex align-items-center mb-2">
|
|
<div
|
|
className="rounded-circle me-2 d-flex align-items-center justify-content-center"
|
|
style={{
|
|
width: '32px',
|
|
height: '32px',
|
|
background: cat.color || '#6b7280',
|
|
minWidth: '32px',
|
|
}}
|
|
>
|
|
<i className={`bi ${cat.icon || 'bi-tag'} text-white small`}></i>
|
|
</div>
|
|
<div className="flex-grow-1">
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<span className="text-white small">{cat.name}</span>
|
|
<div className="text-end">
|
|
<span className="text-slate-400 small">
|
|
{currency(cat.total)} ({cat.percentage}%)
|
|
</span>
|
|
{/* Multi-currency breakdown */}
|
|
{cat.by_currency && Object.keys(cat.by_currency).length > 1 && (
|
|
<div className="d-flex gap-2 justify-content-end mt-1">
|
|
{Object.entries(cat.by_currency).map(([curr, amt]) => (
|
|
<span key={curr} className="badge bg-secondary small" style={{ fontSize: '0.65rem' }}>
|
|
{curr}: {Number(amt).toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="progress mt-1" style={{ height: '4px', background: '#1e293b' }}>
|
|
<div
|
|
className="progress-bar"
|
|
style={{
|
|
width: `${cat.percentage}%`,
|
|
background: cat.color || '#6b7280',
|
|
}}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Trends */}
|
|
{data.category_analysis?.category_trends?.length > 0 && (
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-arrow-left-right me-2"></i>
|
|
{t('financialHealth.categories.trends')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="row g-3">
|
|
{safeCategoryTrends.map((trend, idx) => (
|
|
<div key={idx} className="col-md-4 col-lg-2">
|
|
<div
|
|
className="p-3 rounded text-center"
|
|
style={{
|
|
background: '#1e293b',
|
|
borderLeft: `3px solid ${trend.trend === 'increasing' ? '#ef4444' : '#22c55e'}`,
|
|
}}
|
|
>
|
|
<small className="text-slate-400 d-block">{trend.category}</small>
|
|
<div className={`fs-5 fw-bold ${trend.trend === 'increasing' ? 'text-danger' : 'text-success'}`}>
|
|
<i className={`bi bi-arrow-${trend.trend === 'increasing' ? 'up' : 'down'} me-1`}></i>
|
|
{Math.abs(trend.change_percent)}%
|
|
</div>
|
|
<small className="text-slate-500">
|
|
{currency(trend.previous)} → {currency(trend.current)}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Trends Tab */}
|
|
{activeTab === 'trends' && (
|
|
<div className="row g-4">
|
|
{/* Monthly Evolution Chart */}
|
|
<div className="col-lg-8">
|
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-graph-up me-2"></i>
|
|
{t('financialHealth.trends.monthlyEvolution')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
{monthlyChartData && (
|
|
<div style={{ height: '300px' }}>
|
|
<Bar
|
|
data={monthlyChartData}
|
|
options={{
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: { color: '#94a3b8' },
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: '#1e293b' },
|
|
ticks: { color: '#94a3b8' },
|
|
},
|
|
y: {
|
|
grid: { color: '#1e293b' },
|
|
ticks: { color: '#94a3b8' },
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Trend Indicators */}
|
|
<div className="col-lg-4">
|
|
<div className="row g-3">
|
|
{/* Income Trend */}
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<small className="text-slate-400">{t('financialHealth.trends.incomeTrend')}</small>
|
|
<div className="d-flex align-items-center">
|
|
<i className={`bi bi-arrow-${data.trends?.income_trend?.direction === 'increasing' ? 'up text-success' : data.trends?.income_trend?.direction === 'decreasing' ? 'down text-danger' : 'right text-warning'} fs-4 me-2`}></i>
|
|
<span className="text-white">{t(`financialHealth.trend.${data.trends?.income_trend?.direction}`)}</span>
|
|
</div>
|
|
</div>
|
|
<span className="badge bg-success">
|
|
{data.trends?.income_trend?.strength}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expense Trend */}
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<small className="text-slate-400">{t('financialHealth.trends.expenseTrend')}</small>
|
|
<div className="d-flex align-items-center">
|
|
<i className={`bi bi-arrow-${data.trends?.expense_trend?.direction === 'increasing' ? 'up text-danger' : data.trends?.expense_trend?.direction === 'decreasing' ? 'down text-success' : 'right text-warning'} fs-4 me-2`}></i>
|
|
<span className="text-white">{t(`financialHealth.trend.${data.trends?.expense_trend?.direction}`)}</span>
|
|
</div>
|
|
</div>
|
|
<span className={`badge ${data.trends?.expense_trend?.direction === 'decreasing' ? 'bg-success' : 'bg-danger'} bg-opacity-25 ${data.trends?.expense_trend?.direction === 'decreasing' ? 'text-success' : 'text-danger'}`}>
|
|
{data.trends?.expense_trend?.strength}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Savings Trend */}
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<div className="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<small className="text-slate-400">{t('financialHealth.trends.savingsTrend')}</small>
|
|
<div className="d-flex align-items-center">
|
|
<i className={`bi bi-arrow-${data.trends?.savings_trend?.direction === 'increasing' ? 'up text-success' : data.trends?.savings_trend?.direction === 'decreasing' ? 'down text-danger' : 'right text-warning'} fs-4 me-2`}></i>
|
|
<span className="text-white">{t(`financialHealth.trend.${data.trends?.savings_trend?.direction}`)}</span>
|
|
</div>
|
|
</div>
|
|
<span className={`badge ${data.trends?.savings_trend?.direction === 'increasing' ? 'bg-success' : 'bg-danger'} bg-opacity-25 ${data.trends?.savings_trend?.direction === 'increasing' ? 'text-success' : 'text-danger'}`}>
|
|
{data.trends?.savings_trend?.strength}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Monthly Comparison */}
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<small className="text-slate-400 d-block mb-2">{t('financialHealth.trends.monthlyComparison')}</small>
|
|
<div className="row g-2 text-center">
|
|
<div className="col-6">
|
|
<small className="text-slate-500 d-block">{t('financialHealth.income')}</small>
|
|
<span className={data.trends?.monthly_comparison?.income?.change > 0 ? 'text-success' : 'text-danger'}>
|
|
{data.trends?.monthly_comparison?.income?.change > 0 ? '+' : ''}{data.trends?.monthly_comparison?.income?.change}%
|
|
</span>
|
|
</div>
|
|
<div className="col-6">
|
|
<small className="text-slate-500 d-block">{t('financialHealth.expenses')}</small>
|
|
<span className={data.trends?.monthly_comparison?.expenses?.change < 0 ? 'text-success' : 'text-danger'}>
|
|
{data.trends?.monthly_comparison?.expenses?.change > 0 ? '+' : ''}{data.trends?.monthly_comparison?.expenses?.change}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Score History */}
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-clock-history me-2"></i>
|
|
{t('financialHealth.trends.scoreHistory')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
{safeHistory.length > 0 && (
|
|
<div style={{ height: '200px' }}>
|
|
<Line
|
|
data={{
|
|
labels: safeHistory.map(h => h.month_label),
|
|
datasets: [
|
|
{
|
|
label: t('financialHealth.score'),
|
|
data: safeHistory.map(h => h.score),
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: '#3b82f620',
|
|
fill: true,
|
|
tension: 0.4,
|
|
},
|
|
{
|
|
label: t('financialHealth.savingsRate'),
|
|
data: safeHistory.map(h => h.savings_rate),
|
|
borderColor: '#22c55e',
|
|
backgroundColor: 'transparent',
|
|
borderDash: [5, 5],
|
|
tension: 0.4,
|
|
},
|
|
],
|
|
}}
|
|
options={{
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: { color: '#94a3b8' },
|
|
},
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: '#1e293b' },
|
|
ticks: { color: '#94a3b8' },
|
|
},
|
|
y: {
|
|
grid: { color: '#1e293b' },
|
|
ticks: { color: '#94a3b8' },
|
|
min: 0,
|
|
max: 100,
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Insights Tab */}
|
|
{activeTab === 'insights' && (
|
|
<div className="row g-4">
|
|
{/* Insights */}
|
|
<div className="col-lg-8">
|
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-lightbulb me-2"></i>
|
|
{t('financialHealth.insightsTitle')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
{data.insights?.length > 0 ? (
|
|
<div className="d-flex flex-column gap-3">
|
|
{data.insights.map((insight, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="d-flex p-3 rounded"
|
|
style={{
|
|
background: '#1e293b',
|
|
borderLeft: `4px solid ${insight.color}`,
|
|
}}
|
|
>
|
|
<i
|
|
className={`bi ${insight.icon} fs-4 me-3`}
|
|
style={{ color: insight.color }}
|
|
></i>
|
|
<div>
|
|
<h6 className="text-white mb-1">
|
|
{t(insight.title_key, insight.data)}
|
|
</h6>
|
|
<p className="text-slate-400 mb-0 small">
|
|
{t(insight.message_key, insight.data)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-slate-400 text-center py-4">
|
|
{t('financialHealth.noInsights')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommendations */}
|
|
<div className="col-lg-4">
|
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-magic me-2"></i>
|
|
{t('financialHealth.recommendationsTitle')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
{data.recommendations?.length > 0 ? (
|
|
<div className="d-flex flex-column gap-2">
|
|
{data.recommendations.map((rec, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3 rounded"
|
|
style={{ background: '#1e293b' }}
|
|
>
|
|
<div className="d-flex align-items-start">
|
|
<span
|
|
className={`badge me-2 ${rec.priority === 'high' ? 'bg-danger' : 'bg-warning'}`}
|
|
>
|
|
{rec.priority === 'high' ? t('financialHealth.priority.high') : t('financialHealth.priority.medium')}
|
|
</span>
|
|
</div>
|
|
<p className="text-white small mb-2 mt-2">
|
|
{t(rec.action_key, rec)}
|
|
</p>
|
|
{rec.target_amount && (
|
|
<small className="text-slate-400">
|
|
{t('financialHealth.target')}: {currency(rec.target_amount)}
|
|
</small>
|
|
)}
|
|
{rec.monthly_suggestion && (
|
|
<small className="text-slate-400 d-block">
|
|
{t('financialHealth.monthlyTarget')}: {currency(rec.monthly_suggestion)}
|
|
</small>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-slate-400 text-center py-4">
|
|
<i className="bi bi-check-circle fs-1 d-block mb-2 text-success"></i>
|
|
{t('financialHealth.noRecommendations')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Projection */}
|
|
{data.projection && (
|
|
<div className="card border-0 mt-3" style={{ background: '#1e293b' }}>
|
|
<div className="card-body">
|
|
<h6 className="text-white mb-3">
|
|
<i className="bi bi-calendar-check me-2"></i>
|
|
{t('financialHealth.projection.title')}
|
|
</h6>
|
|
<div className="row g-2 text-center">
|
|
<div className="col-6">
|
|
<small className="text-slate-500 d-block">{t('financialHealth.projection.currentExpenses')}</small>
|
|
<span className="text-danger fw-bold">
|
|
{currency(data.projection.current_month?.expenses || 0)}
|
|
</span>
|
|
</div>
|
|
<div className="col-6">
|
|
<small className="text-slate-500 d-block">{t('financialHealth.projection.projected')}</small>
|
|
<span className="text-warning fw-bold">
|
|
{currency(data.projection.projected?.expenses || 0)}
|
|
</span>
|
|
</div>
|
|
<div className="col-12 mt-2">
|
|
<small className="text-slate-500">
|
|
{data.projection.days_remaining} {t('financialHealth.daysRemaining')}
|
|
{data.projection.vs_average?.expenses !== 0 && (
|
|
<span className={data.projection.vs_average.expenses > 0 ? 'text-danger' : 'text-success'}>
|
|
{' '}({data.projection.vs_average.expenses > 0 ? '+' : ''}{data.projection.vs_average.expenses}% {t('financialHealth.vsAverage')})
|
|
</span>
|
|
)}
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FinancialHealth;
|