NEW FEATURES: - Financial Health: Score 0-100, 6 metrics, insights, recommendations - Goals: Create/edit savings goals, contributions, progress tracking - Budgets: Monthly category limits, usage alerts, year summary - Reports: 7 tabs with charts (category, evolution, projection, etc.) BACKEND: - New models: FinancialGoal, GoalContribution, Budget - New controllers: FinancialHealthController, FinancialGoalController, BudgetController, ReportController - New migrations: financial_goals, goal_contributions, budgets FRONTEND: - New pages: FinancialHealth.jsx, Goals.jsx, Budgets.jsx, Reports.jsx - New services: financialHealthService, financialGoalService, budgetService, reportService - Navigation: New 'Planning' group in sidebar Chart.js integration for all visualizations
419 lines
14 KiB
JavaScript
419 lines
14 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,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler,
|
|
} from 'chart.js';
|
|
import { Line } from 'react-chartjs-2';
|
|
|
|
ChartJS.register(
|
|
CategoryScale,
|
|
LinearScale,
|
|
PointElement,
|
|
LineElement,
|
|
Title,
|
|
Tooltip,
|
|
Legend,
|
|
Filler
|
|
);
|
|
|
|
const FinancialHealth = () => {
|
|
const { t } = useTranslation();
|
|
const { currency } = useFormatters();
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [data, setData] = useState(null);
|
|
const [history, setHistory] = useState([]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [healthData, historyData] = await Promise.all([
|
|
financialHealthService.get(),
|
|
financialHealthService.getHistory({ months: 6 })
|
|
]);
|
|
setData(healthData);
|
|
setHistory(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 '#22c55e';
|
|
if (score >= 40) return '#f59e0b';
|
|
if (score >= 20) return '#f97316';
|
|
return '#ef4444';
|
|
};
|
|
|
|
const getScoreLabel = (score) => {
|
|
if (score >= 80) return t('financialHealth.excellent');
|
|
if (score >= 60) return t('financialHealth.good');
|
|
if (score >= 40) return t('financialHealth.regular');
|
|
if (score >= 20) return t('financialHealth.bad');
|
|
return t('financialHealth.critical');
|
|
};
|
|
|
|
const metricConfigs = {
|
|
savingsCapacity: {
|
|
icon: 'bi-piggy-bank',
|
|
color: '#10b981',
|
|
gradient: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
|
},
|
|
debtControl: {
|
|
icon: 'bi-credit-card',
|
|
color: '#3b82f6',
|
|
gradient: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
|
|
},
|
|
budgetManagement: {
|
|
icon: 'bi-wallet2',
|
|
color: '#8b5cf6',
|
|
gradient: 'linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%)',
|
|
},
|
|
investments: {
|
|
icon: 'bi-graph-up-arrow',
|
|
color: '#f59e0b',
|
|
gradient: 'linear-gradient(135deg, #d97706 0%, #f59e0b 100%)',
|
|
},
|
|
emergencyFund: {
|
|
icon: 'bi-shield-check',
|
|
color: '#06b6d4',
|
|
gradient: 'linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)',
|
|
},
|
|
futurePlanning: {
|
|
icon: 'bi-calendar-check',
|
|
color: '#ec4899',
|
|
gradient: 'linear-gradient(135deg, #db2777 0%, #ec4899 100%)',
|
|
},
|
|
};
|
|
|
|
const getInsightIcon = (type) => {
|
|
switch (type) {
|
|
case 'success': return 'bi-check-circle-fill';
|
|
case 'warning': return 'bi-exclamation-triangle-fill';
|
|
case 'danger': return 'bi-x-circle-fill';
|
|
case 'info': return 'bi-info-circle-fill';
|
|
default: return 'bi-lightbulb-fill';
|
|
}
|
|
};
|
|
|
|
const getInsightColor = (type) => {
|
|
switch (type) {
|
|
case 'success': return '#10b981';
|
|
case 'warning': return '#f59e0b';
|
|
case 'danger': return '#ef4444';
|
|
case 'info': return '#3b82f6';
|
|
default: return '#8b5cf6';
|
|
}
|
|
};
|
|
|
|
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>
|
|
No se pudo cargar la información de salud financiera.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const score = data.score;
|
|
const scoreColor = getScoreColor(score);
|
|
|
|
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')}</p>
|
|
</div>
|
|
<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 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-5">
|
|
{/* Score Ring */}
|
|
<div className="position-relative d-inline-block mb-4">
|
|
<svg width="200" height="200" viewBox="0 0 200 200">
|
|
{/* Background circle */}
|
|
<circle
|
|
cx="100"
|
|
cy="100"
|
|
r="85"
|
|
fill="none"
|
|
stroke="#1e293b"
|
|
strokeWidth="15"
|
|
/>
|
|
{/* Progress circle */}
|
|
<circle
|
|
cx="100"
|
|
cy="100"
|
|
r="85"
|
|
fill="none"
|
|
stroke={scoreColor}
|
|
strokeWidth="15"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${(score / 100) * 534} 534`}
|
|
transform="rotate(-90 100 100)"
|
|
style={{
|
|
filter: `drop-shadow(0 0 10px ${scoreColor}50)`,
|
|
transition: 'stroke-dasharray 1s ease-in-out'
|
|
}}
|
|
/>
|
|
{/* Score text */}
|
|
<text
|
|
x="100"
|
|
y="90"
|
|
textAnchor="middle"
|
|
fill={scoreColor}
|
|
fontSize="48"
|
|
fontWeight="bold"
|
|
>
|
|
{score}
|
|
</text>
|
|
<text
|
|
x="100"
|
|
y="115"
|
|
textAnchor="middle"
|
|
fill="#94a3b8"
|
|
fontSize="16"
|
|
>
|
|
de 100
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<h5 className="text-white mb-2">{getScoreLabel(score)}</h5>
|
|
<p className="text-slate-400 small mb-0">
|
|
{t('financialHealth.scoreDescription')}
|
|
</p>
|
|
|
|
{/* History Chart */}
|
|
{history.length > 0 && (
|
|
<div className="mt-4" style={{ height: '100px' }}>
|
|
<Line
|
|
data={{
|
|
labels: history.map(h => h.month),
|
|
datasets: [{
|
|
data: history.map(h => h.score),
|
|
borderColor: scoreColor,
|
|
backgroundColor: `${scoreColor}20`,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 3,
|
|
pointBackgroundColor: scoreColor,
|
|
}],
|
|
}}
|
|
options={{
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { display: false, min: 0, max: 100 },
|
|
},
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics Grid */}
|
|
<div className="col-lg-8">
|
|
<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: config.gradient }}
|
|
>
|
|
<div className="card-body text-white">
|
|
<div className="d-flex align-items-center mb-2">
|
|
<i className={`bi ${config.icon} fs-4 me-2 opacity-75`}></i>
|
|
<span className="small opacity-75">
|
|
{t(`financialHealth.metrics.${key}`)}
|
|
</span>
|
|
</div>
|
|
<h3 className="mb-1">{metric.score}<small className="fs-6 opacity-75">/100</small></h3>
|
|
|
|
{/* Progress bar */}
|
|
<div className="progress bg-white bg-opacity-25" style={{ height: '4px' }}>
|
|
<div
|
|
className="progress-bar bg-white"
|
|
style={{ width: `${metric.score}%` }}
|
|
></div>
|
|
</div>
|
|
|
|
{/* Value if available */}
|
|
{metric.value !== undefined && (
|
|
<small className="opacity-75 mt-2 d-block">
|
|
{typeof metric.value === 'number' && key !== 'emergencyFund'
|
|
? `${metric.value}%`
|
|
: key === 'emergencyFund'
|
|
? `${metric.value} meses`
|
|
: metric.value
|
|
}
|
|
</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insights */}
|
|
<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-lightbulb me-2"></i>
|
|
{t('financialHealth.insights')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="row g-3">
|
|
{data.insights && data.insights.map((insight, index) => (
|
|
<div key={index} className="col-md-6">
|
|
<div
|
|
className="d-flex p-3 rounded"
|
|
style={{
|
|
background: '#1e293b',
|
|
borderLeft: `3px solid ${getInsightColor(insight.type)}`
|
|
}}
|
|
>
|
|
<i
|
|
className={`bi ${getInsightIcon(insight.type)} me-3 fs-5`}
|
|
style={{ color: getInsightColor(insight.type) }}
|
|
></i>
|
|
<div>
|
|
<p className="text-white mb-0 small">{insight.message}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommendations */}
|
|
{data.recommendations && data.recommendations.length > 0 && (
|
|
<div className="col-12">
|
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
|
<div className="card-header border-0 bg-transparent">
|
|
<h6 className="text-white mb-0">
|
|
<i className="bi bi-magic me-2"></i>
|
|
{t('financialHealth.recommendations')}
|
|
</h6>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="d-flex flex-wrap gap-2">
|
|
{data.recommendations.map((rec, index) => (
|
|
<span
|
|
key={index}
|
|
className="badge py-2 px-3"
|
|
style={{
|
|
background: '#0f172a',
|
|
color: '#94a3b8',
|
|
fontSize: '0.85rem'
|
|
}}
|
|
>
|
|
<i className="bi bi-arrow-right me-2 text-primary"></i>
|
|
{rec}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick Stats */}
|
|
<div className="col-12">
|
|
<div className="row g-3">
|
|
<div className="col-md-3">
|
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.totalBalance')}</small>
|
|
<h5 className={`mb-0 ${data.totals?.balance >= 0 ? 'text-success' : 'text-danger'}`}>
|
|
{currency(data.totals?.balance || 0)}
|
|
</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.monthlyIncome')}</small>
|
|
<h5 className="text-success mb-0">{currency(data.totals?.income || 0)}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.monthlyExpenses')}</small>
|
|
<h5 className="text-danger mb-0">{currency(data.totals?.expense || 0)}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="col-md-3">
|
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
|
<div className="card-body py-3">
|
|
<small className="text-slate-400 d-block">{t('financialHealth.savingsRate')}</small>
|
|
<h5 className="text-primary mb-0">{data.totals?.savings_rate || 0}%</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FinancialHealth;
|