webmoney/frontend/src/pages/FinancialHealth.jsx
marcoitaloesp-ai 854e90e23c
v1.32.0 - Financial Planning Suite: Health Score, Goals, Budgets & Reports
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
2025-12-14 16:31:45 +00:00

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;