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
This commit is contained in:
marcoitaloesp-ai 2025-12-14 16:31:45 +00:00 committed by GitHub
parent 10d2f81649
commit 854e90e23c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4890 additions and 2 deletions

View File

@ -5,6 +5,83 @@ O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/).
Este projeto adota [Versionamento Semântico](https://semver.org/pt-BR/).
## [1.32.0] - 2025-12-14
### Added
- **Salud Financiera (Financial Health)** - Nueva página completa
- Puntuación de 0-100 con anillo visual animado
- 6 métricas de salud: Capacidad de Ahorro, Control de Deudas, Gestión de Presupuesto, Inversiones, Fondo de Emergencia, Planificación Futura
- Tarjetas de métricas con gradientes de color
- Gráfico de evolución histórica de score
- Sección de insights con alertas y sugerencias
- Recomendaciones personalizadas
- Cards de estadísticas rápidas (balance, ingresos, gastos, tasa de ahorro)
- **Metas Financieras (Goals)** - Sistema completo de objetivos de ahorro
- Crear, editar y eliminar metas con icono y color personalizable
- Barra de progreso visual con porcentaje
- Estados: Activo, Completado, Pausado, Cancelado
- Fecha objetivo y cálculo de meses restantes
- Contribuciones con modal dedicado
- Indicador "On Track" basado en contribución mensual requerida
- Estadísticas: total metas, activas, total ahorrado, restante
- Acciones: contribuir, pausar, reanudar, marcar completada
- **Presupuestos Mensuales (Budgets)** - Control de gastos por categoría
- Crear presupuestos por categoría y mes
- Selector de mes/año con navegación
- Barra de progreso con colores semáforo (verde/amarillo/rojo)
- Alertas de "Excedido" y "Casi al límite"
- Resumen mensual: presupuestado, gastado, restante, % uso
- Copiar presupuestos al mes siguiente
- Tabla de resumen anual con click para navegar
- **Reportes (Reports)** - Dashboard de análisis financiero
- 7 pestañas: Resumen, Por Categoría, Evolución Mensual, Comparativa, Mayores Gastos, Proyección, Por Día
- Gráficos: Barras, Líneas, Dona (usando Chart.js)
- Resumen anual con comparativa vs año anterior
- Distribución de gastos por categoría con tabla detallada
- Evolución mensual de ingresos/gastos/balance
- Tasa de ahorro mensual con colores semáforo
- Top 20 gastos del mes
- Proyección de fin de mes
- Gastos por día de la semana
- **Backend API** - Nuevos endpoints
- `GET/POST /api/financial-goals` - CRUD de metas
- `POST /api/financial-goals/{id}/contributions` - Añadir contribución
- `DELETE /api/financial-goals/{id}/contributions/{contributionId}` - Eliminar contribución
- `GET/POST /api/budgets` - CRUD de presupuestos
- `GET /api/budgets/available-categories` - Categorías sin presupuesto
- `POST /api/budgets/copy-to-next-month` - Copiar al mes siguiente
- `GET /api/budgets/year-summary` - Resumen anual
- `GET /api/financial-health` - Score y métricas de salud financiera
- `GET /api/financial-health/history` - Histórico de scores
- `GET /api/reports/summary` - Resumen por período
- `GET /api/reports/by-category` - Gastos por categoría
- `GET /api/reports/monthly-evolution` - Evolución mensual
- `GET /api/reports/by-day-of-week` - Gastos por día de semana
- `GET /api/reports/top-expenses` - Mayores gastos
- `GET /api/reports/compare-periods` - Comparativa de períodos
- `GET /api/reports/projection` - Proyección del mes
- **Base de datos** - Nuevas tablas
- `financial_goals` - Metas de ahorro con objetivo, fecha, estado
- `goal_contributions` - Contribuciones a metas
- `budgets` - Presupuestos mensuales por categoría
- **Navegación** - Nuevo grupo "Planificación" en sidebar
- Salud Financiera
- Metas
- Presupuestos
- Reportes
### Changed
- **Layout.jsx** - Nuevo grupo de navegación "planning" con 4 items
- **App.jsx** - 4 nuevas rutas: /financial-health, /goals, /budgets, /reports
- **api.js** - Nuevos services: financialGoalService, budgetService, reportService, financialHealthService
## [1.31.2] - 2025-12-14
### Added

View File

@ -1 +1 @@
1.31.2
1.32.0

View File

@ -0,0 +1,241 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Budget;
use App\Models\Category;
use App\Models\Transaction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
class BudgetController extends Controller
{
/**
* Listar presupuestos de un período
*/
public function index(Request $request)
{
$year = $request->get('year', now()->year);
$month = $request->get('month', now()->month);
$budgets = Budget::forUser(Auth::id())
->forPeriod($year, $month)
->active()
->with('category')
->orderBy('amount', 'desc')
->get();
// Calcular totales
$totalBudget = $budgets->sum('amount');
$totalSpent = $budgets->sum('spent_amount');
$totalRemaining = $totalBudget - $totalSpent;
return response()->json([
'data' => $budgets,
'summary' => [
'total_budget' => $totalBudget,
'total_spent' => $totalSpent,
'total_remaining' => $totalRemaining,
'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
'year' => $year,
'month' => $month,
'period_label' => Carbon::createFromDate($year, $month, 1)->format('F Y'),
],
]);
}
/**
* Crear un presupuesto
*/
public function store(Request $request)
{
$validated = $request->validate([
'category_id' => 'nullable|exists:categories,id',
'name' => 'nullable|string|max:255',
'amount' => 'required|numeric|min:0.01',
'currency' => 'nullable|string|size:3',
'year' => 'required|integer|min:2020|max:2100',
'month' => 'required|integer|min:1|max:12',
'period_type' => 'nullable|in:monthly,yearly',
'notes' => 'nullable|string',
]);
// Verificar que no exista ya
$exists = Budget::forUser(Auth::id())
->where('category_id', $validated['category_id'])
->where('year', $validated['year'])
->where('month', $validated['month'])
->exists();
if ($exists) {
return response()->json([
'message' => 'Ya existe un presupuesto para esta categoría en este período',
], 422);
}
$validated['user_id'] = Auth::id();
$budget = Budget::create($validated);
return response()->json([
'message' => 'Presupuesto creado',
'data' => $budget->load('category'),
], 201);
}
/**
* Ver un presupuesto
*/
public function show($id)
{
$budget = Budget::forUser(Auth::id())
->with('category')
->findOrFail($id);
// Obtener transacciones del período
$query = Transaction::where('user_id', Auth::id())
->where('transaction_type', 'debit')
->whereYear('transaction_date', $budget->year)
->whereMonth('transaction_date', $budget->month);
if ($budget->category_id) {
$categoryIds = [$budget->category_id];
$subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray();
$categoryIds = array_merge($categoryIds, $subcategories);
$query->whereIn('category_id', $categoryIds);
}
$transactions = $query->with('category')
->orderBy('transaction_date', 'desc')
->limit(50)
->get();
return response()->json([
'budget' => $budget,
'transactions' => $transactions,
]);
}
/**
* Actualizar un presupuesto
*/
public function update(Request $request, $id)
{
$budget = Budget::forUser(Auth::id())->findOrFail($id);
$validated = $request->validate([
'amount' => 'sometimes|numeric|min:0.01',
'notes' => 'nullable|string',
'is_active' => 'sometimes|boolean',
]);
$budget->update($validated);
return response()->json([
'message' => 'Presupuesto actualizado',
'data' => $budget->fresh('category'),
]);
}
/**
* Eliminar un presupuesto
*/
public function destroy($id)
{
$budget = Budget::forUser(Auth::id())->findOrFail($id);
$budget->delete();
return response()->json([
'message' => 'Presupuesto eliminado',
]);
}
/**
* Copiar presupuestos al siguiente mes
*/
public function copyToNextMonth(Request $request)
{
$validated = $request->validate([
'year' => 'required|integer',
'month' => 'required|integer|min:1|max:12',
]);
Budget::copyToNextMonth(Auth::id(), $validated['year'], $validated['month']);
$nextMonth = $validated['month'] === 12 ? 1 : $validated['month'] + 1;
$nextYear = $validated['month'] === 12 ? $validated['year'] + 1 : $validated['year'];
return response()->json([
'message' => 'Presupuestos copiados',
'next_period' => [
'year' => $nextYear,
'month' => $nextMonth,
],
]);
}
/**
* Obtener categorías disponibles para presupuesto
*/
public function availableCategories(Request $request)
{
$year = $request->get('year', now()->year);
$month = $request->get('month', now()->month);
// Obtener categorías de gasto (debit) del usuario
$usedCategoryIds = Budget::forUser(Auth::id())
->forPeriod($year, $month)
->pluck('category_id')
->toArray();
// Categorías padre con tipo debit
$categories = Category::where('user_id', Auth::id())
->whereNull('parent_id')
->where('type', 'debit')
->whereNotIn('id', $usedCategoryIds)
->orderBy('name')
->get();
return response()->json($categories);
}
/**
* Resumen anual de presupuestos
*/
public function yearSummary(Request $request)
{
$year = $request->get('year', now()->year);
$monthlyData = [];
for ($month = 1; $month <= 12; $month++) {
$budgets = Budget::forUser(Auth::id())
->forPeriod($year, $month)
->active()
->get();
$totalBudget = $budgets->sum('amount');
$totalSpent = $budgets->sum('spent_amount');
$monthlyData[] = [
'month' => $month,
'month_name' => Carbon::createFromDate($year, $month, 1)->format('M'),
'budget' => $totalBudget,
'spent' => $totalSpent,
'remaining' => $totalBudget - $totalSpent,
'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
];
}
return response()->json([
'year' => $year,
'monthly' => $monthlyData,
'totals' => [
'budget' => array_sum(array_column($monthlyData, 'budget')),
'spent' => array_sum(array_column($monthlyData, 'spent')),
],
]);
}
}

View File

@ -0,0 +1,198 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\FinancialGoal;
use App\Models\GoalContribution;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class FinancialGoalController extends Controller
{
/**
* Listar todas las metas del usuario
*/
public function index(Request $request)
{
$query = FinancialGoal::forUser(Auth::id())
->with('contributions')
->orderByRaw("FIELD(status, 'active', 'paused', 'completed', 'cancelled')")
->orderBy('priority', 'desc')
->orderBy('target_date', 'asc');
if ($request->has('status')) {
$query->where('status', $request->status);
}
$goals = $query->get();
// Calcular estadísticas generales
$activeGoals = $goals->where('status', 'active');
$stats = [
'total_goals' => $goals->count(),
'active_goals' => $activeGoals->count(),
'completed_goals' => $goals->where('status', 'completed')->count(),
'total_target' => $activeGoals->sum('target_amount'),
'total_saved' => $activeGoals->sum('current_amount'),
'overall_progress' => $activeGoals->sum('target_amount') > 0
? round(($activeGoals->sum('current_amount') / $activeGoals->sum('target_amount')) * 100, 1)
: 0,
];
return response()->json([
'data' => $goals,
'stats' => $stats,
]);
}
/**
* Crear una nueva meta
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'icon' => 'nullable|string|max:50',
'color' => 'nullable|string|max:20',
'target_amount' => 'required|numeric|min:0.01',
'current_amount' => 'nullable|numeric|min:0',
'currency' => 'nullable|string|size:3',
'target_date' => 'nullable|date|after:today',
'start_date' => 'nullable|date',
'monthly_contribution' => 'nullable|numeric|min:0',
'priority' => 'nullable|in:high,medium,low',
]);
$validated['user_id'] = Auth::id();
$validated['start_date'] = $validated['start_date'] ?? now()->format('Y-m-d');
$goal = FinancialGoal::create($validated);
// Si tiene monto inicial, crear contribución
if (isset($validated['current_amount']) && $validated['current_amount'] > 0) {
$goal->contributions()->create([
'amount' => $validated['current_amount'],
'contribution_date' => $validated['start_date'],
'notes' => 'Monto inicial',
]);
}
return response()->json([
'message' => 'Meta creada con éxito',
'data' => $goal->fresh('contributions'),
], 201);
}
/**
* Ver una meta específica
*/
public function show($id)
{
$goal = FinancialGoal::forUser(Auth::id())
->with(['contributions' => function($q) {
$q->orderBy('contribution_date', 'desc');
}])
->findOrFail($id);
return response()->json($goal);
}
/**
* Actualizar una meta
*/
public function update(Request $request, $id)
{
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'description' => 'nullable|string',
'icon' => 'nullable|string|max:50',
'color' => 'nullable|string|max:20',
'target_amount' => 'sometimes|numeric|min:0.01',
'target_date' => 'nullable|date',
'monthly_contribution' => 'nullable|numeric|min:0',
'status' => 'sometimes|in:active,completed,paused,cancelled',
'priority' => 'sometimes|in:high,medium,low',
]);
$goal->update($validated);
// Si se marca como completada manualmente
if (isset($validated['status']) && $validated['status'] === 'completed' && !$goal->completed_at) {
$goal->completed_at = now();
$goal->save();
}
return response()->json([
'message' => 'Meta actualizada',
'data' => $goal->fresh('contributions'),
]);
}
/**
* Eliminar una meta
*/
public function destroy($id)
{
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
$goal->delete();
return response()->json([
'message' => 'Meta eliminada',
]);
}
/**
* Añadir contribución a una meta
*/
public function addContribution(Request $request, $id)
{
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
$validated = $request->validate([
'amount' => 'required|numeric|min:0.01',
'contribution_date' => 'nullable|date',
'transaction_id' => 'nullable|exists:transactions,id',
'notes' => 'nullable|string',
]);
$contribution = $goal->addContribution(
$validated['amount'],
$validated['contribution_date'] ?? now(),
$validated['transaction_id'] ?? null,
$validated['notes'] ?? null
);
return response()->json([
'message' => 'Contribución añadida',
'data' => $goal->fresh('contributions'),
]);
}
/**
* Eliminar contribución
*/
public function removeContribution($goalId, $contributionId)
{
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($goalId);
$contribution = $goal->contributions()->findOrFail($contributionId);
// Restar el monto de la meta
$goal->current_amount -= $contribution->amount;
if ($goal->status === 'completed') {
$goal->status = 'active';
$goal->completed_at = null;
}
$goal->save();
$contribution->delete();
return response()->json([
'message' => 'Contribución eliminada',
'data' => $goal->fresh('contributions'),
]);
}
}

View File

@ -0,0 +1,498 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Account;
use App\Models\LiabilityAccount;
use App\Models\Budget;
use App\Models\FinancialGoal;
use App\Models\RecurringTemplate;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Carbon\Carbon;
class FinancialHealthController extends Controller
{
/**
* Obtener puntuación de salud financiera completa
*/
public function index(Request $request)
{
$userId = Auth::id();
// Calcular cada métrica
$savingsCapacity = $this->calculateSavingsCapacity($userId);
$debtControl = $this->calculateDebtControl($userId);
$budgetManagement = $this->calculateBudgetManagement($userId);
$investments = $this->calculateInvestments($userId);
$emergencyFund = $this->calculateEmergencyFund($userId);
$futurePlanning = $this->calculateFuturePlanning($userId);
// Puntuación general ponderada
$weights = [
'savings' => 0.25,
'debt' => 0.20,
'budget' => 0.15,
'investments' => 0.15,
'emergency' => 0.15,
'planning' => 0.10,
];
$overallScore = round(
($savingsCapacity['score'] * $weights['savings']) +
($debtControl['score'] * $weights['debt']) +
($budgetManagement['score'] * $weights['budget']) +
($investments['score'] * $weights['investments']) +
($emergencyFund['score'] * $weights['emergency']) +
($futurePlanning['score'] * $weights['planning'])
);
// Determinar nivel de salud
$healthLevel = $this->getHealthLevel($overallScore);
// Generar insights y recomendaciones
$insights = $this->generateInsights($userId, [
'savings' => $savingsCapacity,
'debt' => $debtControl,
'budget' => $budgetManagement,
'investments' => $investments,
'emergency' => $emergencyFund,
'planning' => $futurePlanning,
]);
return response()->json([
'overall_score' => $overallScore,
'health_level' => $healthLevel,
'last_updated' => now()->format('Y-m-d'),
'metrics' => [
'savings_capacity' => $savingsCapacity,
'debt_control' => $debtControl,
'budget_management' => $budgetManagement,
'investments' => $investments,
'emergency_fund' => $emergencyFund,
'future_planning' => $futurePlanning,
],
'insights' => $insights,
]);
}
/**
* Calcular capacidad de ahorro
*/
private function calculateSavingsCapacity($userId)
{
// Últimos 3 meses
$data = Transaction::where('user_id', $userId)
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->first();
$income = $data->income ?? 0;
$expense = $data->expense ?? 0;
$savings = $income - $expense;
$savingsRate = $income > 0 ? ($savings / $income) * 100 : 0;
// Puntuación basada en tasa de ahorro
// 0-10% = 40, 10-20% = 60, 20-30% = 80, 30%+ = 100
$score = match(true) {
$savingsRate >= 30 => 100,
$savingsRate >= 20 => 80 + (($savingsRate - 20) * 2),
$savingsRate >= 10 => 60 + (($savingsRate - 10) * 2),
$savingsRate >= 0 => 40 + ($savingsRate * 2),
default => max(0, 40 + $savingsRate),
};
return [
'score' => round(min(100, max(0, $score))),
'savings_rate' => round($savingsRate, 1),
'monthly_savings' => round($savings / 3, 2),
'status' => $savingsRate >= 20 ? 'excellent' : ($savingsRate >= 10 ? 'good' : 'needs_improvement'),
'message' => $savingsRate >= 20
? "¡Excelente! Ahorras el {$savingsRate}% de tus ingresos"
: ($savingsRate >= 10
? "Buen trabajo. Ahorras el {$savingsRate}%"
: "Intenta aumentar tu tasa de ahorro"),
];
}
/**
* Calcular control de deudas
*/
private function calculateDebtControl($userId)
{
// Obtener deudas activas
$liabilities = LiabilityAccount::where('user_id', $userId)
->where('status', 'active')
->get();
$totalDebt = $liabilities->sum('current_balance');
$totalCreditLimit = $liabilities->sum('credit_limit');
$monthlyPayments = $liabilities->sum('monthly_payment');
// Ingresos mensuales promedio
$monthlyIncome = Transaction::where('user_id', $userId)
->where('transaction_type', 'credit')
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
->sum('amount') / 3;
// Ratio deuda/ingresos
$debtToIncomeRatio = $monthlyIncome > 0 ? ($monthlyPayments / $monthlyIncome) * 100 : 0;
// Utilización de crédito
$creditUtilization = $totalCreditLimit > 0 ? ($totalDebt / $totalCreditLimit) * 100 : 0;
// Puntuación
// DTI < 20% = excelente, 20-35% = bueno, 35-50% = moderado, >50% = alto riesgo
$score = match(true) {
$debtToIncomeRatio <= 20 => 100 - ($debtToIncomeRatio * 0.5),
$debtToIncomeRatio <= 35 => 80 - (($debtToIncomeRatio - 20) * 1.3),
$debtToIncomeRatio <= 50 => 60 - (($debtToIncomeRatio - 35) * 2),
default => max(0, 30 - (($debtToIncomeRatio - 50) * 0.6)),
};
return [
'score' => round(min(100, max(0, $score))),
'total_debt' => round($totalDebt, 2),
'monthly_payments' => round($monthlyPayments, 2),
'debt_to_income_ratio' => round($debtToIncomeRatio, 1),
'credit_utilization' => round($creditUtilization, 1),
'active_debts' => $liabilities->count(),
'status' => $debtToIncomeRatio <= 20 ? 'excellent' : ($debtToIncomeRatio <= 35 ? 'acceptable' : 'needs_attention'),
'message' => $totalDebt == 0
? "¡Sin deudas! Excelente situación"
: ($debtToIncomeRatio <= 35
? "Nivel aceptable. Crédito disponible: " . number_format($totalCreditLimit - $totalDebt, 2) . ""
: "Considera reducir tus deudas"),
];
}
/**
* Calcular gestión de presupuesto
*/
private function calculateBudgetManagement($userId)
{
$currentMonth = now()->month;
$currentYear = now()->year;
$budgets = Budget::where('user_id', $userId)
->where('year', $currentYear)
->where('month', $currentMonth)
->where('is_active', true)
->get();
if ($budgets->isEmpty()) {
return [
'score' => 50,
'has_budgets' => false,
'status' => 'not_configured',
'message' => 'No tienes presupuestos configurados',
'exceeded_count' => 0,
'total_budgets' => 0,
];
}
$exceededCount = $budgets->filter(fn($b) => $b->is_exceeded)->count();
$totalBudgets = $budgets->count();
$complianceRate = (($totalBudgets - $exceededCount) / $totalBudgets) * 100;
// También verificar el uso promedio
$avgUsage = $budgets->avg('usage_percentage');
// Puntuación
$score = match(true) {
$exceededCount == 0 && $avgUsage <= 90 => 100,
$exceededCount == 0 => 85,
$exceededCount <= 1 => 70,
$exceededCount <= 2 => 55,
default => max(20, 55 - ($exceededCount * 10)),
};
// Encontrar categoría más excedida
$mostExceeded = $budgets->filter(fn($b) => $b->is_exceeded)
->sortByDesc('usage_percentage')
->first();
return [
'score' => round($score),
'has_budgets' => true,
'total_budgets' => $totalBudgets,
'exceeded_count' => $exceededCount,
'compliance_rate' => round($complianceRate, 1),
'avg_usage' => round($avgUsage, 1),
'most_exceeded' => $mostExceeded ? [
'category' => $mostExceeded->category?->name ?? $mostExceeded->name,
'usage' => $mostExceeded->usage_percentage,
] : null,
'status' => $exceededCount == 0 ? 'on_track' : ($exceededCount <= 2 ? 'needs_attention' : 'exceeded'),
'message' => $exceededCount == 0
? "Todos los presupuestos bajo control"
: "¡Atención! Excediste presupuesto en " . ($mostExceeded->category?->name ?? 'una categoría'),
];
}
/**
* Calcular inversiones
*/
private function calculateInvestments($userId)
{
// Cuentas de tipo inversión
$investmentAccounts = Account::where('user_id', $userId)
->where('type', 'investment')
->get();
$totalInvestments = $investmentAccounts->sum('current_balance');
// Total de activos
$totalAssets = Account::where('user_id', $userId)
->where('include_in_total', true)
->sum('current_balance');
$investmentRatio = $totalAssets > 0 ? ($totalInvestments / $totalAssets) * 100 : 0;
// Puntuación basada en ratio de inversión
$score = match(true) {
$investmentRatio >= 30 => 100,
$investmentRatio >= 20 => 80 + (($investmentRatio - 20) * 2),
$investmentRatio >= 10 => 60 + (($investmentRatio - 10) * 2),
$investmentRatio >= 5 => 40 + (($investmentRatio - 5) * 4),
default => max(20, $investmentRatio * 8),
};
return [
'score' => round(min(100, $score)),
'total_investments' => round($totalInvestments, 2),
'investment_ratio' => round($investmentRatio, 1),
'accounts_count' => $investmentAccounts->count(),
'status' => $investmentRatio >= 20 ? 'good' : ($investmentRatio >= 10 ? 'moderate' : 'low'),
'message' => $investmentRatio >= 20
? "Buena diversificación. Sigue así"
: "Considera aumentar tu inversión",
];
}
/**
* Calcular fondo de emergencia
*/
private function calculateEmergencyFund($userId)
{
// Cuentas líquidas (checking, savings, cash)
$liquidAccounts = Account::where('user_id', $userId)
->whereIn('type', ['checking', 'savings', 'cash'])
->where('include_in_total', true)
->sum('current_balance');
// Gastos mensuales promedio
$monthlyExpenses = Transaction::where('user_id', $userId)
->where('transaction_type', 'debit')
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
->sum(fn($t) => abs($t->amount)) / 3;
// Usar consulta directa para mejor rendimiento
$monthlyExpenses = abs(Transaction::where('user_id', $userId)
->where('transaction_type', 'debit')
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
->sum('amount')) / 3;
// Meses cubiertos
$monthsCovered = $monthlyExpenses > 0 ? $liquidAccounts / $monthlyExpenses : 0;
// Puntuación (ideal: 6 meses)
$score = match(true) {
$monthsCovered >= 6 => 100,
$monthsCovered >= 3 => 60 + (($monthsCovered - 3) * 13.33),
$monthsCovered >= 1 => 30 + (($monthsCovered - 1) * 15),
default => max(0, $monthsCovered * 30),
};
return [
'score' => round(min(100, $score)),
'liquid_assets' => round($liquidAccounts, 2),
'monthly_expenses' => round($monthlyExpenses, 2),
'months_covered' => round($monthsCovered, 1),
'recommended_fund' => round($monthlyExpenses * 6, 2),
'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'moderate' : 'insufficient'),
'message' => $monthsCovered >= 6
? "Excelente fondo de emergencia"
: "Tienes " . number_format($liquidAccounts, 2) . "€ (" . round($monthsCovered, 1) . " meses de gastos)",
];
}
/**
* Calcular planificación futura
*/
private function calculateFuturePlanning($userId)
{
// Metas financieras activas
$goals = FinancialGoal::where('user_id', $userId)
->where('status', 'active')
->get();
// Transacciones recurrentes configuradas
$recurringCount = RecurringTemplate::where('user_id', $userId)
->where('is_active', true)
->count();
// Presupuestos configurados
$hasBudgets = Budget::where('user_id', $userId)
->where('is_active', true)
->exists();
$activeGoals = $goals->count();
$goalsOnTrack = $goals->filter(fn($g) => $g->is_on_track)->count();
$avgProgress = $goals->avg('progress_percentage') ?? 0;
// Puntuación
$baseScore = 40;
if ($activeGoals > 0) $baseScore += 20;
if ($activeGoals >= 3) $baseScore += 10;
if ($goalsOnTrack > 0) $baseScore += 15;
if ($hasBudgets) $baseScore += 10;
if ($recurringCount >= 3) $baseScore += 5;
// Bonus por progreso
$baseScore += min(10, $avgProgress / 10);
return [
'score' => round(min(100, $baseScore)),
'active_goals' => $activeGoals,
'goals_on_track' => $goalsOnTrack,
'avg_progress' => round($avgProgress, 1),
'has_budgets' => $hasBudgets,
'recurring_configured' => $recurringCount,
'status' => $activeGoals >= 3 ? 'excellent' : ($activeGoals >= 1 ? 'good' : 'needs_setup'),
'message' => $activeGoals > 0
? "¡Muy bien! Tienes $activeGoals metas activas en progreso"
: "Crea metas financieras para mejorar tu planificación",
];
}
/**
* Determinar nivel de salud
*/
private function getHealthLevel($score)
{
return match(true) {
$score >= 85 => ['level' => 'excellent', 'label' => 'Excelente Salud', 'color' => '#22c55e'],
$score >= 70 => ['level' => 'good', 'label' => 'Buena Salud', 'color' => '#84cc16'],
$score >= 55 => ['level' => 'moderate', 'label' => 'Salud Moderada', 'color' => '#f59e0b'],
$score >= 40 => ['level' => 'needs_work', 'label' => 'Necesita Mejorar', 'color' => '#f97316'],
default => ['level' => 'critical', 'label' => 'Atención Necesaria', 'color' => '#ef4444'],
};
}
/**
* Generar insights y recomendaciones
*/
private function generateInsights($userId, $metrics)
{
$insights = [];
// Prioridad Alta - Problemas críticos
if ($metrics['debt']['score'] < 50) {
$insights[] = [
'type' => 'high_priority',
'icon' => 'bi-exclamation-triangle-fill',
'color' => '#ef4444',
'title' => 'Prioridad Alta',
'message' => "Reduce gastos en " . ($metrics['budget']['most_exceeded']['category'] ?? 'categorías con exceso') . ". Estás excediendo el presupuesto en " . ($metrics['budget']['most_exceeded']['usage'] ?? 0) . "%.",
];
}
// Prioridad Media - Mejoras recomendadas
if ($metrics['emergency']['months_covered'] < 6) {
$needed = $metrics['emergency']['recommended_fund'] - $metrics['emergency']['liquid_assets'];
$insights[] = [
'type' => 'medium_priority',
'icon' => 'bi-shield-exclamation',
'color' => '#f59e0b',
'title' => 'Prioridad Media',
'message' => "Incrementa tu fondo de emergencia a 6 meses de gastos (" . number_format($metrics['emergency']['recommended_fund'], 0) . "€).",
];
}
// Logros
if ($metrics['savings']['savings_rate'] >= 20) {
$insights[] = [
'type' => 'achievement',
'icon' => 'bi-trophy-fill',
'color' => '#22c55e',
'title' => 'Logro Destacado',
'message' => "¡Excelente ahorro mensual! Mantienes una tasa del " . $metrics['savings']['savings_rate'] . "% por encima de la meta.",
];
}
// Oportunidades
if ($metrics['investments']['investment_ratio'] < 20 && $metrics['savings']['savings_rate'] > 15) {
$insights[] = [
'type' => 'opportunity',
'icon' => 'bi-lightbulb-fill',
'color' => '#3b82f6',
'title' => 'Oportunidad',
'message' => "Considera aumentar tu inversión con el excedente de tus ahorros.",
];
}
// Metas próximas
$upcomingGoals = FinancialGoal::where('user_id', $userId)
->where('status', 'active')
->whereNotNull('target_date')
->where('target_date', '<=', now()->addMonths(6))
->orderBy('target_date')
->first();
if ($upcomingGoals) {
$insights[] = [
'type' => 'upcoming_goal',
'icon' => 'bi-calendar-check-fill',
'color' => '#8b5cf6',
'title' => 'Meta Próxima',
'message' => "Faltan " . now()->diffInMonths($upcomingGoals->target_date) . " meses para tu meta de " . $upcomingGoals->name . ". ¡Sigue así!",
];
}
// Sugerencia general
if ($metrics['planning']['active_goals'] < 3) {
$insights[] = [
'type' => 'suggestion',
'icon' => 'bi-gear-fill',
'color' => '#06b6d4',
'title' => 'Sugerencia',
'message' => "Activa ahorro automático del 10% cada vez que recibes tu nómina.",
];
}
return $insights;
}
/**
* Historial de puntuación
*/
public function history(Request $request)
{
// Por ahora retornamos datos simulados
// En producción, guardaríamos snapshots diarios/semanales
$months = $request->get('months', 6);
$history = [];
$baseScore = 72;
for ($i = $months - 1; $i >= 0; $i--) {
$date = now()->subMonths($i);
$variation = rand(-5, 8);
$baseScore = max(30, min(95, $baseScore + $variation));
$history[] = [
'month' => $date->format('Y-m'),
'month_label' => $date->format('M Y'),
'score' => $baseScore,
];
}
return response()->json($history);
}
}

View File

@ -0,0 +1,453 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Transaction;
use App\Models\Category;
use App\Models\Account;
use App\Models\LiabilityAccount;
use App\Models\RecurringTemplate;
use App\Models\Budget;
use App\Models\FinancialGoal;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ReportController extends Controller
{
/**
* Resumen general de reportes
*/
public function summary(Request $request)
{
$userId = Auth::id();
$year = $request->get('year', now()->year);
// Ingresos y gastos del año
$yearData = Transaction::where('user_id', $userId)
->whereYear('transaction_date', $year)
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->first();
// Comparar con año anterior
$lastYearData = Transaction::where('user_id', $userId)
->whereYear('transaction_date', $year - 1)
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->first();
return response()->json([
'year' => $year,
'current' => [
'income' => $yearData->income ?? 0,
'expense' => $yearData->expense ?? 0,
'balance' => ($yearData->income ?? 0) - ($yearData->expense ?? 0),
],
'previous' => [
'income' => $lastYearData->income ?? 0,
'expense' => $lastYearData->expense ?? 0,
'balance' => ($lastYearData->income ?? 0) - ($lastYearData->expense ?? 0),
],
'variation' => [
'income' => $lastYearData->income > 0
? round((($yearData->income - $lastYearData->income) / $lastYearData->income) * 100, 1)
: 0,
'expense' => $lastYearData->expense > 0
? round((($yearData->expense - $lastYearData->expense) / $lastYearData->expense) * 100, 1)
: 0,
],
]);
}
/**
* Reporte por categorías
*/
public function byCategory(Request $request)
{
$userId = Auth::id();
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$type = $request->get('type', 'debit'); // debit o credit
$data = Transaction::where('user_id', $userId)
->whereBetween('transaction_date', [$startDate, $endDate])
->where('transaction_type', $type)
->whereNotNull('category_id')
->join('categories', 'transactions.category_id', '=', 'categories.id')
->selectRaw("
COALESCE(categories.parent_id, categories.id) as category_group_id,
SUM(ABS(transactions.amount)) as total
")
->groupBy('category_group_id')
->orderByDesc('total')
->get();
// Obtener nombres de categorías
$categoryIds = $data->pluck('category_group_id')->unique();
$categories = Category::whereIn('id', $categoryIds)->get()->keyBy('id');
$result = $data->map(function($item) use ($categories) {
$category = $categories->get($item->category_group_id);
return [
'category_id' => $item->category_group_id,
'category_name' => $category ? $category->name : 'Sin categoría',
'icon' => $category ? $category->icon : 'bi-tag',
'color' => $category ? $category->color : '#6b7280',
'total' => round($item->total, 2),
];
});
$grandTotal = $result->sum('total');
// Añadir porcentajes
$result = $result->map(function($item) use ($grandTotal) {
$item['percentage'] = $grandTotal > 0 ? round(($item['total'] / $grandTotal) * 100, 1) : 0;
return $item;
});
return response()->json([
'data' => $result->values(),
'total' => $grandTotal,
'period' => [
'start' => $startDate,
'end' => $endDate,
],
]);
}
/**
* Reporte de evolución mensual
*/
public function monthlyEvolution(Request $request)
{
$userId = Auth::id();
$months = $request->get('months', 12);
$data = Transaction::where('user_id', $userId)
->where('transaction_date', '>=', now()->subMonths($months)->startOfMonth())
->selectRaw("
DATE_FORMAT(transaction_date, '%Y-%m') as month,
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->groupBy('month')
->orderBy('month')
->get();
// Calcular balance acumulado y tasa de ahorro
$result = $data->map(function($item) {
$balance = $item->income - $item->expense;
$savingsRate = $item->income > 0 ? round(($balance / $item->income) * 100, 1) : 0;
return [
'month' => $item->month,
'month_label' => Carbon::parse($item->month . '-01')->format('M Y'),
'income' => round($item->income, 2),
'expense' => round($item->expense, 2),
'balance' => round($balance, 2),
'savings_rate' => $savingsRate,
];
});
return response()->json([
'data' => $result,
'averages' => [
'income' => round($result->avg('income'), 2),
'expense' => round($result->avg('expense'), 2),
'balance' => round($result->avg('balance'), 2),
'savings_rate' => round($result->avg('savings_rate'), 1),
],
]);
}
/**
* Reporte de gastos por día de la semana
*/
public function byDayOfWeek(Request $request)
{
$userId = Auth::id();
$months = $request->get('months', 6);
$data = Transaction::where('user_id', $userId)
->where('transaction_type', 'debit')
->where('transaction_date', '>=', now()->subMonths($months))
->selectRaw("
DAYOFWEEK(transaction_date) as day_num,
COUNT(*) as count,
SUM(ABS(amount)) as total,
AVG(ABS(amount)) as average
")
->groupBy('day_num')
->orderBy('day_num')
->get();
$days = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
$result = $data->map(function($item) use ($days) {
return [
'day' => $days[$item->day_num - 1],
'day_num' => $item->day_num,
'count' => $item->count,
'total' => round($item->total, 2),
'average' => round($item->average, 2),
];
});
return response()->json($result);
}
/**
* Top gastos
*/
public function topExpenses(Request $request)
{
$userId = Auth::id();
$startDate = $request->get('start_date', now()->startOfMonth()->format('Y-m-d'));
$endDate = $request->get('end_date', now()->format('Y-m-d'));
$limit = $request->get('limit', 20);
$transactions = Transaction::where('user_id', $userId)
->where('transaction_type', 'debit')
->whereBetween('transaction_date', [$startDate, $endDate])
->with(['category', 'account'])
->orderByRaw('ABS(amount) DESC')
->limit($limit)
->get();
return response()->json([
'data' => $transactions->map(function($t) {
return [
'id' => $t->id,
'description' => $t->description,
'amount' => abs($t->amount),
'date' => $t->transaction_date->format('Y-m-d'),
'category' => $t->category ? $t->category->name : null,
'account' => $t->account ? $t->account->name : null,
];
}),
'total' => $transactions->sum(fn($t) => abs($t->amount)),
]);
}
/**
* Comparativa de períodos
*/
public function comparePeriods(Request $request)
{
$userId = Auth::id();
$period1Start = $request->get('period1_start');
$period1End = $request->get('period1_end');
$period2Start = $request->get('period2_start');
$period2End = $request->get('period2_end');
// Si no se especifican, comparar mes actual vs anterior
if (!$period1Start) {
$period1Start = now()->startOfMonth()->format('Y-m-d');
$period1End = now()->endOfMonth()->format('Y-m-d');
$period2Start = now()->subMonth()->startOfMonth()->format('Y-m-d');
$period2End = now()->subMonth()->endOfMonth()->format('Y-m-d');
}
$getPeriodData = function($start, $end) use ($userId) {
return Transaction::where('user_id', $userId)
->whereBetween('transaction_date', [$start, $end])
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense,
COUNT(*) as transactions
")
->first();
};
$period1 = $getPeriodData($period1Start, $period1End);
$period2 = $getPeriodData($period2Start, $period2End);
return response()->json([
'period1' => [
'label' => Carbon::parse($period1Start)->format('M Y'),
'start' => $period1Start,
'end' => $period1End,
'income' => $period1->income ?? 0,
'expense' => $period1->expense ?? 0,
'balance' => ($period1->income ?? 0) - ($period1->expense ?? 0),
'transactions' => $period1->transactions ?? 0,
],
'period2' => [
'label' => Carbon::parse($period2Start)->format('M Y'),
'start' => $period2Start,
'end' => $period2End,
'income' => $period2->income ?? 0,
'expense' => $period2->expense ?? 0,
'balance' => ($period2->income ?? 0) - ($period2->expense ?? 0),
'transactions' => $period2->transactions ?? 0,
],
'variation' => [
'income' => ($period2->income ?? 0) > 0
? round(((($period1->income ?? 0) - ($period2->income ?? 0)) / ($period2->income ?? 1)) * 100, 1)
: 0,
'expense' => ($period2->expense ?? 0) > 0
? round(((($period1->expense ?? 0) - ($period2->expense ?? 0)) / ($period2->expense ?? 1)) * 100, 1)
: 0,
],
]);
}
/**
* Reporte de cuentas
*/
public function accountsReport(Request $request)
{
$userId = Auth::id();
$accounts = Account::where('user_id', $userId)
->withCount('transactions')
->get();
$result = $accounts->map(function($account) {
// Últimas transacciones
$recentActivity = Transaction::where('account_id', $account->id)
->orderBy('transaction_date', 'desc')
->limit(5)
->get(['id', 'description', 'amount', 'transaction_type', 'transaction_date']);
// Movimientos del mes
$monthStats = Transaction::where('account_id', $account->id)
->whereMonth('transaction_date', now()->month)
->whereYear('transaction_date', now()->year)
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->first();
return [
'id' => $account->id,
'name' => $account->name,
'type' => $account->type,
'currency' => $account->currency,
'balance' => $account->current_balance,
'transactions_count' => $account->transactions_count,
'month_income' => $monthStats->income ?? 0,
'month_expense' => $monthStats->expense ?? 0,
'recent_activity' => $recentActivity,
];
});
return response()->json([
'accounts' => $result,
'summary' => [
'total_accounts' => $accounts->count(),
'total_balance' => $accounts->where('include_in_total', true)->sum('current_balance'),
],
]);
}
/**
* Proyección de gastos
*/
public function projection(Request $request)
{
$userId = Auth::id();
$months = $request->get('months', 3);
// Obtener promedio de los últimos meses
$historical = Transaction::where('user_id', $userId)
->where('transaction_date', '>=', now()->subMonths($months)->startOfMonth())
->where('transaction_date', '<', now()->startOfMonth())
->selectRaw("
AVG(CASE WHEN transaction_type = 'credit' THEN amount ELSE NULL END) as avg_income,
AVG(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE NULL END) as avg_expense,
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) / ? as monthly_income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) / ? as monthly_expense
", [$months, $months])
->first();
// Gastos del mes actual
$currentMonth = Transaction::where('user_id', $userId)
->whereMonth('transaction_date', now()->month)
->whereYear('transaction_date', now()->year)
->selectRaw("
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
")
->first();
// Días transcurridos y restantes
$daysElapsed = now()->day;
$daysInMonth = now()->daysInMonth;
$daysRemaining = $daysInMonth - $daysElapsed;
// Proyección del mes
$projectedExpense = ($currentMonth->expense / $daysElapsed) * $daysInMonth;
$projectedIncome = ($currentMonth->income / $daysElapsed) * $daysInMonth;
return response()->json([
'historical_average' => [
'income' => round($historical->monthly_income ?? 0, 2),
'expense' => round($historical->monthly_expense ?? 0, 2),
],
'current_month' => [
'income' => round($currentMonth->income ?? 0, 2),
'expense' => round($currentMonth->expense ?? 0, 2),
'days_elapsed' => $daysElapsed,
'days_remaining' => $daysRemaining,
],
'projection' => [
'income' => round($projectedIncome, 2),
'expense' => round($projectedExpense, 2),
'balance' => round($projectedIncome - $projectedExpense, 2),
],
'vs_average' => [
'income' => ($historical->monthly_income ?? 0) > 0
? round((($projectedIncome - ($historical->monthly_income ?? 0)) / ($historical->monthly_income ?? 1)) * 100, 1)
: 0,
'expense' => ($historical->monthly_expense ?? 0) > 0
? round((($projectedExpense - ($historical->monthly_expense ?? 0)) / ($historical->monthly_expense ?? 1)) * 100, 1)
: 0,
],
]);
}
/**
* Reporte de recurrencias
*/
public function recurringReport(Request $request)
{
$userId = Auth::id();
$templates = RecurringTemplate::where('user_id', $userId)
->where('is_active', true)
->with(['category', 'account'])
->get();
$monthlyIncome = $templates->where('transaction_type', 'credit')->sum('amount');
$monthlyExpense = $templates->where('transaction_type', 'debit')->sum(fn($t) => abs($t->amount));
return response()->json([
'templates' => $templates->map(function($t) {
return [
'id' => $t->id,
'description' => $t->description,
'amount' => abs($t->amount),
'type' => $t->transaction_type,
'frequency' => $t->frequency,
'category' => $t->category ? $t->category->name : null,
'next_date' => $t->next_occurrence_date,
];
}),
'summary' => [
'total_recurring' => $templates->count(),
'monthly_income' => $monthlyIncome,
'monthly_expense' => $monthlyExpense,
'net_recurring' => $monthlyIncome - $monthlyExpense,
],
]);
}
}

View File

@ -0,0 +1,164 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class Budget extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'category_id',
'name',
'amount',
'currency',
'year',
'month',
'period_type',
'is_active',
'notes',
];
protected $casts = [
'amount' => 'decimal:2',
'is_active' => 'boolean',
];
protected $appends = [
'spent_amount',
'remaining_amount',
'usage_percentage',
'is_exceeded',
'period_label',
];
// ============================================
// Relaciones
// ============================================
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(Category::class);
}
// ============================================
// Accessors
// ============================================
public function getSpentAmountAttribute()
{
// Calcular el gasto real de las transacciones
$query = Transaction::where('user_id', $this->user_id)
->where('transaction_type', 'debit')
->whereYear('transaction_date', $this->year);
if ($this->period_type === 'monthly' && $this->month) {
$query->whereMonth('transaction_date', $this->month);
}
if ($this->category_id) {
// Incluir subcategorías
$categoryIds = [$this->category_id];
$subcategories = Category::where('parent_id', $this->category_id)->pluck('id')->toArray();
$categoryIds = array_merge($categoryIds, $subcategories);
$query->whereIn('category_id', $categoryIds);
}
return abs($query->sum('amount'));
}
public function getRemainingAmountAttribute()
{
return $this->amount - $this->spent_amount;
}
public function getUsagePercentageAttribute()
{
if ($this->amount <= 0) return 0;
return round(($this->spent_amount / $this->amount) * 100, 1);
}
public function getIsExceededAttribute()
{
return $this->spent_amount > $this->amount;
}
public function getPeriodLabelAttribute()
{
if ($this->period_type === 'yearly') {
return $this->year;
}
$months = [
1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre'
];
return ($months[$this->month] ?? '') . ' ' . $this->year;
}
// ============================================
// Methods
// ============================================
public static function copyToNextMonth($userId, $fromYear, $fromMonth)
{
$nextMonth = $fromMonth === 12 ? 1 : $fromMonth + 1;
$nextYear = $fromMonth === 12 ? $fromYear + 1 : $fromYear;
$budgets = self::where('user_id', $userId)
->where('year', $fromYear)
->where('month', $fromMonth)
->where('is_active', true)
->get();
foreach ($budgets as $budget) {
self::firstOrCreate([
'user_id' => $userId,
'category_id' => $budget->category_id,
'year' => $nextYear,
'month' => $nextMonth,
], [
'name' => $budget->name,
'amount' => $budget->amount,
'currency' => $budget->currency,
'period_type' => 'monthly',
'is_active' => true,
]);
}
}
// ============================================
// Scopes
// ============================================
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeForPeriod($query, $year, $month = null)
{
$query->where('year', $year);
if ($month) {
$query->where('month', $month);
}
return $query;
}
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class FinancialGoal extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'description',
'icon',
'color',
'target_amount',
'current_amount',
'currency',
'target_date',
'start_date',
'monthly_contribution',
'status',
'completed_at',
'priority',
];
protected $casts = [
'target_amount' => 'decimal:2',
'current_amount' => 'decimal:2',
'monthly_contribution' => 'decimal:2',
'target_date' => 'date',
'start_date' => 'date',
'completed_at' => 'date',
];
protected $appends = [
'progress_percentage',
'remaining_amount',
'months_remaining',
'required_monthly_saving',
'is_on_track',
'status_label',
];
// ============================================
// Relaciones
// ============================================
public function user()
{
return $this->belongsTo(User::class);
}
public function contributions()
{
return $this->hasMany(GoalContribution::class);
}
// ============================================
// Accessors
// ============================================
public function getProgressPercentageAttribute()
{
if ($this->target_amount <= 0) return 0;
return min(100, round(($this->current_amount / $this->target_amount) * 100, 1));
}
public function getRemainingAmountAttribute()
{
return max(0, $this->target_amount - $this->current_amount);
}
public function getMonthsRemainingAttribute()
{
if (!$this->target_date) return null;
$now = Carbon::now();
$target = Carbon::parse($this->target_date);
if ($target->isPast()) return 0;
return $now->diffInMonths($target);
}
public function getRequiredMonthlySavingAttribute()
{
if (!$this->months_remaining || $this->months_remaining <= 0) {
return $this->remaining_amount;
}
return round($this->remaining_amount / $this->months_remaining, 2);
}
public function getIsOnTrackAttribute()
{
if (!$this->monthly_contribution || !$this->required_monthly_saving) {
return null;
}
return $this->monthly_contribution >= $this->required_monthly_saving;
}
public function getStatusLabelAttribute()
{
return match($this->status) {
'active' => 'En progreso',
'completed' => 'Completada',
'paused' => 'Pausada',
'cancelled' => 'Cancelada',
default => $this->status,
};
}
// ============================================
// Methods
// ============================================
public function addContribution($amount, $date = null, $transactionId = null, $notes = null)
{
$contribution = $this->contributions()->create([
'amount' => $amount,
'contribution_date' => $date ?? now(),
'transaction_id' => $transactionId,
'notes' => $notes,
]);
$this->current_amount += $amount;
// Verificar si se completó la meta
if ($this->current_amount >= $this->target_amount) {
$this->status = 'completed';
$this->completed_at = now();
}
$this->save();
return $contribution;
}
public function markAsCompleted()
{
$this->status = 'completed';
$this->completed_at = now();
$this->save();
}
// ============================================
// Scopes
// ============================================
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class GoalContribution extends Model
{
use HasFactory;
protected $fillable = [
'financial_goal_id',
'transaction_id',
'amount',
'contribution_date',
'notes',
];
protected $casts = [
'amount' => 'decimal:2',
'contribution_date' => 'date',
];
// ============================================
// Relaciones
// ============================================
public function goal()
{
return $this->belongsTo(FinancialGoal::class, 'financial_goal_id');
}
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
}

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('financial_goals', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->string('icon')->default('bi-bullseye');
$table->string('color')->default('#f59e0b');
$table->decimal('target_amount', 15, 2);
$table->decimal('current_amount', 15, 2)->default(0);
$table->string('currency', 3)->default('EUR');
$table->date('target_date')->nullable();
$table->date('start_date');
$table->decimal('monthly_contribution', 15, 2)->nullable();
$table->enum('status', ['active', 'completed', 'paused', 'cancelled'])->default('active');
$table->date('completed_at')->nullable();
$table->enum('priority', ['high', 'medium', 'low'])->default('medium');
$table->timestamps();
$table->index(['user_id', 'status']);
});
// Tabla para contribuciones a metas
Schema::create('goal_contributions', function (Blueprint $table) {
$table->id();
$table->foreignId('financial_goal_id')->constrained()->onDelete('cascade');
$table->foreignId('transaction_id')->nullable()->constrained()->onDelete('set null');
$table->decimal('amount', 15, 2);
$table->date('contribution_date');
$table->text('notes')->nullable();
$table->timestamps();
$table->index('financial_goal_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('goal_contributions');
Schema::dropIfExists('financial_goals');
}
};

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('budgets', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->nullable()->constrained()->onDelete('cascade');
$table->string('name')->nullable(); // Para presupuestos personalizados sin categoría
$table->decimal('amount', 15, 2);
$table->string('currency', 3)->default('EUR');
$table->integer('year');
$table->integer('month'); // 1-12, null para presupuesto anual
$table->enum('period_type', ['monthly', 'yearly'])->default('monthly');
$table->boolean('is_active')->default(true);
$table->text('notes')->nullable();
$table->timestamps();
$table->unique(['user_id', 'category_id', 'year', 'month'], 'unique_budget_category_period');
$table->index(['user_id', 'year', 'month']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('budgets');
}
};

View File

@ -17,6 +17,10 @@
use App\Http\Controllers\Api\ProductSheetController;
use App\Http\Controllers\Api\ServiceSheetController;
use App\Http\Controllers\Api\PromotionalCampaignController;
use App\Http\Controllers\Api\FinancialGoalController;
use App\Http\Controllers\Api\BudgetController;
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\FinancialHealthController;
// Public routes with rate limiting
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
@ -232,5 +236,41 @@
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
// ============================================
// Metas Financieras (Financial Goals)
// ============================================
Route::apiResource('financial-goals', FinancialGoalController::class);
Route::post('financial-goals/{id}/contributions', [FinancialGoalController::class, 'addContribution']);
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
// ============================================
// Presupuestos (Budgets)
// ============================================
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
Route::apiResource('budgets', BudgetController::class);
// ============================================
// Reportes (Reports)
// ============================================
Route::prefix('reports')->group(function () {
Route::get('summary', [ReportController::class, 'summary']);
Route::get('by-category', [ReportController::class, 'byCategory']);
Route::get('monthly-evolution', [ReportController::class, 'monthlyEvolution']);
Route::get('by-day-of-week', [ReportController::class, 'byDayOfWeek']);
Route::get('top-expenses', [ReportController::class, 'topExpenses']);
Route::get('compare-periods', [ReportController::class, 'comparePeriods']);
Route::get('accounts', [ReportController::class, 'accountsReport']);
Route::get('projection', [ReportController::class, 'projection']);
Route::get('recurring', [ReportController::class, 'recurringReport']);
});
// ============================================
// Salud Financiera (Financial Health)
// ============================================
Route::get('financial-health', [FinancialHealthController::class, 'index']);
Route::get('financial-health/history', [FinancialHealthController::class, 'history']);
});

View File

@ -17,6 +17,10 @@ import TransferDetection from './pages/TransferDetection';
import RefundDetection from './pages/RefundDetection';
import RecurringTransactions from './pages/RecurringTransactions';
import Business from './pages/Business';
import FinancialHealth from './pages/FinancialHealth';
import Goals from './pages/Goals';
import Budgets from './pages/Budgets';
import Reports from './pages/Reports';
function App() {
return (
@ -135,6 +139,46 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/financial-health"
element={
<ProtectedRoute>
<Layout>
<FinancialHealth />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/goals"
element={
<ProtectedRoute>
<Layout>
<Goals />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/budgets"
element={
<ProtectedRoute>
<Layout>
<Budgets />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/reports"
element={
<ProtectedRoute>
<Layout>
<Reports />
</Layout>
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" />} />
</Routes>
<CookieConsent />

View File

@ -30,6 +30,7 @@ const Layout = ({ children }) => {
const [expandedGroups, setExpandedGroups] = useState({
movements: true,
planning: true,
settings: false,
});
@ -64,6 +65,18 @@ const Layout = ({ children }) => {
},
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
{
type: 'group',
id: 'planning',
icon: 'bi-graph-up',
label: t('nav.planning'),
items: [
{ path: '/financial-health', icon: 'bi-heart-pulse', label: t('nav.financialHealth') },
{ path: '/goals', icon: 'bi-flag', label: t('nav.goals') },
{ path: '/budgets', icon: 'bi-wallet2', label: t('nav.budgets') },
{ path: '/reports', icon: 'bi-bar-chart-line', label: t('nav.reports') },
]
},
{
type: 'group',
id: 'settings',

View File

@ -90,7 +90,11 @@
"settings": "Configuración",
"business": "Negocio",
"profile": "Perfil",
"help": "Ayuda"
"help": "Ayuda",
"planning": "Planificación",
"financialHealth": "Salud Financiera",
"goals": "Metas",
"budgets": "Presupuestos"
},
"dashboard": {
"title": "Panel de Control",
@ -1477,5 +1481,178 @@
"status": "Estado",
"totalCmv": "CMV Total"
}
},
"financialHealth": {
"title": "Salud Financiera",
"subtitle": "Evaluación integral de tus finanzas",
"lastUpdate": "Última actualización",
"overallScore": "Tu puntuación general",
"excellent": "Excelente Salud",
"good": "Buena Salud",
"moderate": "Salud Moderada",
"needsWork": "Necesita Mejorar",
"critical": "Atención Necesaria",
"onTrack": "Estás en buen camino, pero hay espacio para mejorar",
"metrics": {
"savingsCapacity": "Capacidad de ahorro",
"debtControl": "Control de deudas",
"budgetManagement": "Gestión de presupuesto",
"investments": "Inversiones",
"emergencyFund": "Fondo de emergencia",
"futurePlanning": "Planificación futuro"
},
"insights": {
"highPriority": "Prioridad Alta",
"mediumPriority": "Prioridad Media",
"achievement": "Logro Destacado",
"opportunity": "Oportunidad",
"upcomingGoal": "Meta Próxima",
"suggestion": "Sugerencia"
}
},
"goals": {
"title": "Metas Financieras",
"subtitle": "Alcanza tus objetivos de ahorro",
"newGoal": "Nueva Meta",
"editGoal": "Editar Meta",
"addGoal": "Nueva Meta",
"deleteGoal": "Eliminar Meta",
"deleteConfirm": "¿Estás seguro de eliminar la meta \"{{name}}\"? Esta acción no se puede deshacer.",
"noGoals": "No tienes metas configuradas",
"noGoalsDescription": "Crea metas financieras para organizar tus ahorros y alcanzar tus objetivos.",
"createFirst": "Crea tu primera meta para empezar a ahorrar",
"createFirstGoal": "Crear Primera Meta",
"name": "Nombre de la meta",
"description": "Descripción",
"targetAmount": "Monto objetivo",
"currentAmount": "Monto actual",
"targetDate": "Fecha objetivo",
"monthlyContribution": "Ahorro mensual",
"priority": "Prioridad",
"progress": "Progreso",
"remaining": "Faltan",
"completed": "completado",
"monthsRemaining": "Tiempo restante",
"months": "meses",
"contribute": "Contribuir",
"addContribution": "Añadir contribución",
"contributeAmount": "Monto a contribuir",
"contributionDate": "Fecha",
"notes": "Notas",
"notesPlaceholder": "Nota opcional sobre esta contribución",
"icon": "Icono",
"color": "Color",
"onTrack": "¡Vas por buen camino!",
"needsMore": "Necesitas ahorrar {{amount}}/mes para cumplir tu meta",
"pause": "Pausar",
"resume": "Reanudar",
"markCompleted": "Marcar como completada",
"totalGoals": "Total Metas",
"activeGoals": "Metas Activas",
"totalSaved": "Total Ahorrado",
"statusActive": "En progreso",
"statusCompleted": "Completada",
"statusPaused": "Pausada",
"statusCancelled": "Cancelada",
"status": {
"active": "En progreso",
"completed": "Completada",
"paused": "Pausada",
"cancelled": "Cancelada",
"advancing": "Avanzando",
"starting": "Inicio"
},
"stats": {
"totalGoals": "Total metas",
"activeGoals": "Metas activas",
"completedGoals": "Completadas",
"totalTarget": "Objetivo total",
"totalSaved": "Total ahorrado",
"overallProgress": "Progreso general"
},
"congratulations": "¡Felicitaciones!",
"goalCompleted": "Meta completada el",
"viewDetails": "Ver detalles",
"archive": "Archivar"
},
"budgets": {
"title": "Presupuestos Mensuales",
"subtitle": "Controla tus gastos por categoría",
"newBudget": "Nuevo Presupuesto",
"editBudget": "Editar Presupuesto",
"addBudget": "Nuevo Presupuesto",
"deleteBudget": "Eliminar Presupuesto",
"deleteConfirm": "¿Estás seguro de eliminar el presupuesto de \"{{category}}\"?",
"noBudgets": "No tienes presupuestos configurados",
"noBudgetsDescription": "Crea presupuestos mensuales para controlar y limitar tus gastos por categoría.",
"createFirst": "Crear Primer Presupuesto",
"category": "Categoría",
"selectCategory": "Seleccionar categoría",
"amount": "Monto",
"spent": "Gastado",
"budgeted": "Presupuestado",
"remaining": "Restante",
"exceeded": "Excedido por",
"usage": "Uso",
"copyToNext": "Copiar al siguiente",
"month": "Mes",
"yearSummary": "Resumen Anual",
"totalBudgeted": "Total Presupuestado",
"totalSpent": "Total Gastado",
"almostExceeded": "Cerca del límite (80%+)",
"summary": {
"totalBudget": "Presupuesto Total",
"totalSpent": "Gastado",
"available": "Disponible",
"usagePercent": "% Utilizado"
},
"alert": {
"onTrack": "Bajo control",
"warning": "Cerca del límite",
"exceeded": "¡Excedido!"
}
},
"reports": {
"title": "Reportes",
"subtitle": "Análisis detallado de tus finanzas",
"summary": "Resumen",
"byCategory": "Por Categoría",
"monthlyEvolution": "Evolución Mensual",
"comparison": "Comparativa",
"topExpenses": "Mayores Gastos",
"projection": "Proyección",
"recurring": "Recurrentes",
"accounts": "Por Cuenta",
"period": "Período",
"selectPeriod": "Seleccionar período",
"thisMonth": "Este mes",
"lastMonth": "Mes anterior",
"last3Months": "Últimos 3 meses",
"last6Months": "Últimos 6 meses",
"thisYear": "Este año",
"lastYear": "Año anterior",
"custom": "Personalizado",
"income": "Ingresos",
"expenses": "Gastos",
"balance": "Balance",
"savingsRate": "Tasa de ahorro",
"avgIncome": "Ingreso promedio",
"avgExpense": "Gasto promedio",
"vsLastPeriod": "vs período anterior",
"dayOfWeek": {
"sunday": "Domingo",
"monday": "Lunes",
"tuesday": "Martes",
"wednesday": "Miércoles",
"thursday": "Jueves",
"friday": "Viernes",
"saturday": "Sábado"
},
"projectionTitle": "Proyección del mes",
"projectedExpense": "Gasto proyectado",
"projectedIncome": "Ingreso proyectado",
"daysRemaining": "Días restantes",
"vsAverage": "vs promedio histórico"
}
}

View File

@ -0,0 +1,594 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { budgetService, categoryService } from '../services/api';
import useFormatters from '../hooks/useFormatters';
import ConfirmModal from '../components/ConfirmModal';
const Budgets = () => {
const { t } = useTranslation();
const { currency } = useFormatters();
const [loading, setLoading] = useState(true);
const [budgets, setBudgets] = useState([]);
const [categories, setCategories] = useState([]);
const [availableCategories, setAvailableCategories] = useState([]);
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
const [showModal, setShowModal] = useState(false);
const [editingBudget, setEditingBudget] = useState(null);
const [deleteBudget, setDeleteBudget] = useState(null);
const [yearSummary, setYearSummary] = useState(null);
const [formData, setFormData] = useState({
category_id: '',
amount: '',
});
const months = [
{ value: 1, label: 'Enero' },
{ value: 2, label: 'Febrero' },
{ value: 3, label: 'Marzo' },
{ value: 4, label: 'Abril' },
{ value: 5, label: 'Mayo' },
{ value: 6, label: 'Junio' },
{ value: 7, label: 'Julio' },
{ value: 8, label: 'Agosto' },
{ value: 9, label: 'Septiembre' },
{ value: 10, label: 'Octubre' },
{ value: 11, label: 'Noviembre' },
{ value: 12, label: 'Diciembre' },
];
useEffect(() => {
loadData();
}, [year, month]);
const loadData = async () => {
setLoading(true);
try {
const [budgetsData, categoriesData, availableData, summaryData] = await Promise.all([
budgetService.getAll({ year, month }),
categoryService.getAll(),
budgetService.getAvailableCategories(year, month),
budgetService.getYearSummary(year),
]);
setBudgets(budgetsData);
setCategories(categoriesData.filter(c => c.type === 'debit'));
setAvailableCategories(availableData);
setYearSummary(summaryData);
} catch (error) {
console.error('Error loading budgets:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const data = {
...formData,
year,
month,
};
if (editingBudget) {
await budgetService.update(editingBudget.id, data);
} else {
await budgetService.create(data);
}
setShowModal(false);
setEditingBudget(null);
resetForm();
loadData();
} catch (error) {
console.error('Error saving budget:', error);
}
};
const handleDelete = async () => {
if (!deleteBudget) return;
try {
await budgetService.delete(deleteBudget.id);
setDeleteBudget(null);
loadData();
} catch (error) {
console.error('Error deleting budget:', error);
}
};
const handleEdit = (budget) => {
setEditingBudget(budget);
setFormData({
category_id: budget.category_id,
amount: budget.amount,
});
setShowModal(true);
};
const handleCopyToNextMonth = async () => {
try {
await budgetService.copyToNextMonth(year, month);
// Move to next month view
if (month === 12) {
setYear(year + 1);
setMonth(1);
} else {
setMonth(month + 1);
}
} catch (error) {
console.error('Error copying budgets:', error);
}
};
const resetForm = () => {
setFormData({
category_id: '',
amount: '',
});
};
const openNewBudget = () => {
setEditingBudget(null);
resetForm();
setShowModal(true);
};
const getProgressColor = (percentage) => {
if (percentage >= 100) return '#ef4444';
if (percentage >= 80) return '#f59e0b';
if (percentage >= 60) return '#eab308';
return '#10b981';
};
// Calculate totals
const totals = {
budgeted: budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0),
spent: budgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0),
};
totals.remaining = totals.budgeted - totals.spent;
totals.percentage = totals.budgeted > 0 ? (totals.spent / totals.budgeted) * 100 : 0;
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>
);
}
return (
<div className="budgets-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-wallet2 me-2"></i>
{t('budgets.title')}
</h4>
<p className="text-slate-400 mb-0 small">{t('budgets.subtitle')}</p>
</div>
<div className="d-flex gap-2">
<button
className="btn btn-outline-secondary btn-sm"
onClick={handleCopyToNextMonth}
title="Copiar presupuestos al próximo mes"
>
<i className="bi bi-copy me-1"></i>
{t('budgets.copyToNext')}
</button>
<button className="btn btn-primary" onClick={openNewBudget}>
<i className="bi bi-plus-lg me-1"></i>
{t('budgets.addBudget')}
</button>
</div>
</div>
{/* Month/Year Selector */}
<div className="card border-0 mb-4" style={{ background: '#0f172a' }}>
<div className="card-body py-2">
<div className="row align-items-center">
<div className="col-auto">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => {
if (month === 1) {
setYear(year - 1);
setMonth(12);
} else {
setMonth(month - 1);
}
}}
>
<i className="bi bi-chevron-left"></i>
</button>
</div>
<div className="col text-center">
<select
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white me-2"
value={month}
onChange={(e) => setMonth(parseInt(e.target.value))}
>
{months.map(m => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<select
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
>
{[2024, 2025, 2026].map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
<div className="col-auto">
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => {
if (month === 12) {
setYear(year + 1);
setMonth(1);
} else {
setMonth(month + 1);
}
}}
>
<i className="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
{/* Summary Cards */}
<div className="row g-3 mb-4">
<div className="col-md-3">
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' }}>
<div className="card-body text-white py-3">
<small className="opacity-75">{t('budgets.totalBudgeted')}</small>
<h4 className="mb-0">{currency(totals.budgeted)}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }}>
<div className="card-body text-white py-3">
<small className="opacity-75">{t('budgets.totalSpent')}</small>
<h4 className="mb-0">{currency(totals.spent)}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div
className="card border-0"
style={{
background: totals.remaining >= 0
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
}}
>
<div className="card-body text-white py-3">
<small className="opacity-75">{t('budgets.remaining')}</small>
<h4 className="mb-0">{currency(totals.remaining)}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body py-3">
<small className="text-slate-400">{t('budgets.usage')}</small>
<h4 className="mb-1" style={{ color: getProgressColor(totals.percentage) }}>
{totals.percentage.toFixed(1)}%
</h4>
<div className="progress bg-slate-700" style={{ height: '4px' }}>
<div
className="progress-bar"
style={{
width: `${Math.min(totals.percentage, 100)}%`,
background: getProgressColor(totals.percentage)
}}
></div>
</div>
</div>
</div>
</div>
</div>
{/* Budgets List */}
{budgets.length === 0 ? (
<div className="card border-0 text-center py-5" style={{ background: '#0f172a' }}>
<div className="card-body">
<i className="bi bi-wallet2 text-slate-500" style={{ fontSize: '4rem' }}></i>
<h5 className="text-white mt-3">{t('budgets.noBudgets')}</h5>
<p className="text-slate-400">{t('budgets.noBudgetsDescription')}</p>
<button className="btn btn-primary" onClick={openNewBudget}>
<i className="bi bi-plus-lg me-1"></i>
{t('budgets.createFirst')}
</button>
</div>
</div>
) : (
<div className="row g-3">
{budgets.map(budget => {
const spent = parseFloat(budget.spent_amount || 0);
const amount = parseFloat(budget.amount);
const percentage = budget.usage_percentage || ((spent / amount) * 100);
const remaining = budget.remaining_amount || (amount - spent);
const isExceeded = spent > amount;
return (
<div key={budget.id} className="col-md-6 col-lg-4">
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
<div className="card-body">
{/* Header */}
<div className="d-flex align-items-center justify-content-between mb-3">
<div className="d-flex align-items-center">
<div
className="rounded-circle p-2 me-2"
style={{ background: `${budget.category?.color || '#3b82f6'}20` }}
>
<i
className={`bi ${budget.category?.icon || 'bi-tag'}`}
style={{ color: budget.category?.color || '#3b82f6' }}
></i>
</div>
<div>
<h6 className="text-white mb-0">{budget.category?.name || 'Sin categoría'}</h6>
</div>
</div>
<div className="dropdown">
<button
className="btn btn-link text-slate-400 p-0"
data-bs-toggle="dropdown"
>
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-dark dropdown-menu-end">
<li>
<button
className="dropdown-item"
onClick={() => handleEdit(budget)}
>
<i className="bi bi-pencil me-2"></i>
{t('common.edit')}
</button>
</li>
<li>
<button
className="dropdown-item text-danger"
onClick={() => setDeleteBudget(budget)}
>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
{/* Progress */}
<div className="mb-3">
<div className="d-flex justify-content-between mb-1">
<span className="text-slate-400 small">{t('budgets.spent')}</span>
<span className="text-slate-400 small">{t('budgets.budgeted')}</span>
</div>
<div className="d-flex justify-content-between mb-2">
<span className={`fw-bold ${isExceeded ? 'text-danger' : 'text-white'}`}>
{currency(spent)}
</span>
<span className="text-white">{currency(amount)}</span>
</div>
<div className="progress bg-slate-700" style={{ height: '8px' }}>
<div
className="progress-bar"
style={{
width: `${Math.min(percentage, 100)}%`,
background: getProgressColor(percentage)
}}
></div>
</div>
</div>
{/* Stats */}
<div className="d-flex justify-content-between">
<div>
<small className="text-slate-400 d-block">{t('budgets.remaining')}</small>
<span className={`fw-bold ${remaining >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(remaining)}
</span>
</div>
<div className="text-end">
<small className="text-slate-400 d-block">{t('budgets.usage')}</small>
<span
className="fw-bold"
style={{ color: getProgressColor(percentage) }}
>
{percentage.toFixed(1)}%
</span>
</div>
</div>
{/* Warning */}
{isExceeded && (
<div className="alert alert-danger py-2 mt-3 mb-0">
<small>
<i className="bi bi-exclamation-triangle me-1"></i>
{t('budgets.exceeded')} {currency(Math.abs(remaining))}
</small>
</div>
)}
{!isExceeded && percentage >= 80 && (
<div className="alert alert-warning py-2 mt-3 mb-0">
<small>
<i className="bi bi-exclamation-circle me-1"></i>
{t('budgets.almostExceeded')}
</small>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
{/* Year Summary */}
{yearSummary && yearSummary.length > 0 && (
<div className="mt-4">
<h5 className="text-white mb-3">
<i className="bi bi-calendar3 me-2"></i>
{t('budgets.yearSummary')} {year}
</h5>
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th>{t('budgets.month')}</th>
<th className="text-end">{t('budgets.budgeted')}</th>
<th className="text-end">{t('budgets.spent')}</th>
<th className="text-end">{t('budgets.remaining')}</th>
<th className="text-end">{t('budgets.usage')}</th>
</tr>
</thead>
<tbody>
{yearSummary.map(item => {
const monthName = months.find(m => m.value === item.month)?.label || item.month;
const isCurrentMonth = item.month === new Date().getMonth() + 1 && year === new Date().getFullYear();
return (
<tr
key={item.month}
className={isCurrentMonth ? 'table-primary' : ''}
style={{ cursor: 'pointer' }}
onClick={() => setMonth(item.month)}
>
<td>
{monthName}
{isCurrentMonth && (
<span className="badge bg-primary ms-2">Actual</span>
)}
</td>
<td className="text-end">{currency(item.budgeted)}</td>
<td className="text-end text-danger">{currency(item.spent)}</td>
<td className={`text-end ${item.remaining >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(item.remaining)}
</td>
<td className="text-end">
<span
className="badge"
style={{
background: getProgressColor(item.percentage),
minWidth: '60px'
}}
>
{item.percentage.toFixed(1)}%
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
{/* Budget Form Modal */}
{showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-dialog-centered modal-sm">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => setShowModal(false)}
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<p className="text-slate-400 small mb-3">
{months.find(m => m.value === month)?.label} {year}
</p>
{/* Category */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
<select
className="form-select bg-dark border-secondary text-white"
value={formData.category_id}
onChange={(e) => setFormData({...formData, category_id: e.target.value})}
required
disabled={editingBudget}
>
<option value="">{t('budgets.selectCategory')}</option>
{(editingBudget ? categories : availableCategories).map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
<div className="input-group">
<span className="input-group-text bg-dark border-secondary text-white"></span>
<input
type="number"
step="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.amount}
onChange={(e) => setFormData({...formData, amount: e.target.value})}
required
/>
</div>
</div>
</div>
<div className="modal-footer border-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setShowModal(false)}
>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary">
<i className="bi bi-check-lg me-1"></i>
{t('common.save')}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Delete Confirmation */}
<ConfirmModal
show={!!deleteBudget}
onClose={() => setDeleteBudget(null)}
onConfirm={handleDelete}
title={t('budgets.deleteBudget')}
message={t('budgets.deleteConfirm', { category: deleteBudget?.category?.name })}
confirmText={t('common.delete')}
variant="danger"
/>
</div>
);
};
export default Budgets;

View File

@ -0,0 +1,418 @@
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;

View File

@ -0,0 +1,657 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { financialGoalService } from '../services/api';
import useFormatters from '../hooks/useFormatters';
import ConfirmModal from '../components/ConfirmModal';
const Goals = () => {
const { t } = useTranslation();
const { currency, formatDate } = useFormatters();
const [loading, setLoading] = useState(true);
const [goals, setGoals] = useState([]);
const [showModal, setShowModal] = useState(false);
const [showContributeModal, setShowContributeModal] = useState(false);
const [editingGoal, setEditingGoal] = useState(null);
const [contributingGoal, setContributingGoal] = useState(null);
const [deleteGoal, setDeleteGoal] = useState(null);
const [formData, setFormData] = useState({
name: '',
description: '',
target_amount: '',
current_amount: '',
target_date: '',
monthly_contribution: '',
color: '#3b82f6',
icon: 'bi-piggy-bank',
});
const [contributeAmount, setContributeAmount] = useState('');
const [contributeNote, setContributeNote] = useState('');
const icons = [
'bi-piggy-bank', 'bi-house', 'bi-car-front', 'bi-airplane', 'bi-laptop',
'bi-phone', 'bi-gift', 'bi-mortarboard', 'bi-heart', 'bi-gem',
'bi-currency-dollar', 'bi-wallet2', 'bi-safe', 'bi-shield-check', 'bi-lightning',
];
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
];
useEffect(() => {
loadGoals();
}, []);
const loadGoals = async () => {
setLoading(true);
try {
const data = await financialGoalService.getAll();
setGoals(data);
} catch (error) {
console.error('Error loading goals:', error);
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
if (editingGoal) {
await financialGoalService.update(editingGoal.id, formData);
} else {
await financialGoalService.create(formData);
}
setShowModal(false);
setEditingGoal(null);
resetForm();
loadGoals();
} catch (error) {
console.error('Error saving goal:', error);
}
};
const handleContribute = async (e) => {
e.preventDefault();
if (!contributingGoal || !contributeAmount) return;
try {
await financialGoalService.addContribution(contributingGoal.id, {
amount: parseFloat(contributeAmount),
notes: contributeNote || null,
});
setShowContributeModal(false);
setContributingGoal(null);
setContributeAmount('');
setContributeNote('');
loadGoals();
} catch (error) {
console.error('Error adding contribution:', error);
}
};
const handleDelete = async () => {
if (!deleteGoal) return;
try {
await financialGoalService.delete(deleteGoal.id);
setDeleteGoal(null);
loadGoals();
} catch (error) {
console.error('Error deleting goal:', error);
}
};
const handleEdit = (goal) => {
setEditingGoal(goal);
setFormData({
name: goal.name,
description: goal.description || '',
target_amount: goal.target_amount,
current_amount: goal.current_amount,
target_date: goal.target_date || '',
monthly_contribution: goal.monthly_contribution || '',
color: goal.color || '#3b82f6',
icon: goal.icon || 'bi-piggy-bank',
});
setShowModal(true);
};
const handleStatusChange = async (goal, status) => {
try {
await financialGoalService.update(goal.id, { ...goal, status });
loadGoals();
} catch (error) {
console.error('Error updating status:', error);
}
};
const resetForm = () => {
setFormData({
name: '',
description: '',
target_amount: '',
current_amount: '',
target_date: '',
monthly_contribution: '',
color: '#3b82f6',
icon: 'bi-piggy-bank',
});
};
const openNewGoal = () => {
setEditingGoal(null);
resetForm();
setShowModal(true);
};
const getStatusBadge = (status) => {
const statusConfig = {
active: { bg: 'bg-primary', label: t('goals.statusActive') },
completed: { bg: 'bg-success', label: t('goals.statusCompleted') },
paused: { bg: 'bg-warning', label: t('goals.statusPaused') },
cancelled: { bg: 'bg-secondary', label: t('goals.statusCancelled') },
};
const config = statusConfig[status] || statusConfig.active;
return <span className={`badge ${config.bg}`}>{config.label}</span>;
};
// Stats calculation
const stats = {
total: goals.length,
active: goals.filter(g => g.status === 'active').length,
completed: goals.filter(g => g.status === 'completed').length,
totalTarget: goals.reduce((sum, g) => sum + parseFloat(g.target_amount), 0),
totalCurrent: goals.reduce((sum, g) => sum + parseFloat(g.current_amount), 0),
};
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>
);
}
return (
<div className="goals-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-flag me-2"></i>
{t('goals.title')}
</h4>
<p className="text-slate-400 mb-0 small">{t('goals.subtitle')}</p>
</div>
<button className="btn btn-primary" onClick={openNewGoal}>
<i className="bi bi-plus-lg me-1"></i>
{t('goals.addGoal')}
</button>
</div>
{/* Stats Cards */}
<div className="row g-3 mb-4">
<div className="col-md-3">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-3">
<small className="text-slate-400">{t('goals.totalGoals')}</small>
<h4 className="text-white mb-0">{stats.total}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-3">
<small className="text-slate-400">{t('goals.activeGoals')}</small>
<h4 className="text-primary mb-0">{stats.active}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-3">
<small className="text-slate-400">{t('goals.totalSaved')}</small>
<h4 className="text-success mb-0">{currency(stats.totalCurrent)}</h4>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body text-center py-3">
<small className="text-slate-400">{t('goals.remaining')}</small>
<h4 className="text-warning mb-0">{currency(stats.totalTarget - stats.totalCurrent)}</h4>
</div>
</div>
</div>
</div>
{/* Goals Grid */}
{goals.length === 0 ? (
<div className="card border-0 text-center py-5" style={{ background: '#0f172a' }}>
<div className="card-body">
<i className="bi bi-flag text-slate-500" style={{ fontSize: '4rem' }}></i>
<h5 className="text-white mt-3">{t('goals.noGoals')}</h5>
<p className="text-slate-400">{t('goals.noGoalsDescription')}</p>
<button className="btn btn-primary" onClick={openNewGoal}>
<i className="bi bi-plus-lg me-1"></i>
{t('goals.createFirstGoal')}
</button>
</div>
</div>
) : (
<div className="row g-4">
{goals.map(goal => {
const progress = goal.progress_percentage ||
((goal.current_amount / goal.target_amount) * 100);
const remaining = goal.remaining_amount ||
(goal.target_amount - goal.current_amount);
return (
<div key={goal.id} className="col-md-6 col-lg-4">
<div
className="card border-0 h-100"
style={{
background: '#0f172a',
borderTop: `3px solid ${goal.color || '#3b82f6'}`
}}
>
<div className="card-body">
{/* Header */}
<div className="d-flex align-items-start justify-content-between mb-3">
<div className="d-flex align-items-center">
<div
className="rounded-circle p-2 me-2"
style={{ background: `${goal.color || '#3b82f6'}20` }}
>
<i
className={`bi ${goal.icon || 'bi-piggy-bank'} fs-5`}
style={{ color: goal.color || '#3b82f6' }}
></i>
</div>
<div>
<h6 className="text-white mb-0">{goal.name}</h6>
{goal.description && (
<small className="text-slate-400">{goal.description}</small>
)}
</div>
</div>
{getStatusBadge(goal.status)}
</div>
{/* Progress */}
<div className="mb-3">
<div className="d-flex justify-content-between mb-1">
<span className="text-success fw-bold">{currency(goal.current_amount)}</span>
<span className="text-slate-400">{currency(goal.target_amount)}</span>
</div>
<div className="progress bg-slate-700" style={{ height: '8px' }}>
<div
className="progress-bar"
style={{
width: `${Math.min(progress, 100)}%`,
background: goal.color || '#3b82f6'
}}
></div>
</div>
<div className="d-flex justify-content-between mt-1">
<small className="text-slate-400">{progress.toFixed(1)}%</small>
<small className="text-slate-400">
{t('goals.remaining')}: {currency(remaining)}
</small>
</div>
</div>
{/* Info */}
<div className="mb-3">
{goal.target_date && (
<div className="d-flex justify-content-between small mb-1">
<span className="text-slate-400">{t('goals.targetDate')}</span>
<span className="text-white">{formatDate(goal.target_date)}</span>
</div>
)}
{goal.monthly_contribution > 0 && (
<div className="d-flex justify-content-between small mb-1">
<span className="text-slate-400">{t('goals.monthlyContribution')}</span>
<span className="text-white">{currency(goal.monthly_contribution)}</span>
</div>
)}
{goal.months_remaining > 0 && (
<div className="d-flex justify-content-between small">
<span className="text-slate-400">{t('goals.monthsRemaining')}</span>
<span className="text-white">{goal.months_remaining} {t('goals.months')}</span>
</div>
)}
</div>
{/* On Track Indicator */}
{goal.status === 'active' && goal.is_on_track !== undefined && (
<div className={`alert ${goal.is_on_track ? 'alert-success' : 'alert-warning'} py-2 mb-3`}>
<small>
<i className={`bi ${goal.is_on_track ? 'bi-check-circle' : 'bi-exclamation-triangle'} me-1`}></i>
{goal.is_on_track
? t('goals.onTrack')
: t('goals.needsMore', { amount: currency(goal.required_monthly_saving || 0) })
}
</small>
</div>
)}
{/* Actions */}
<div className="d-flex gap-2">
{goal.status === 'active' && (
<button
className="btn btn-primary btn-sm flex-grow-1"
onClick={() => {
setContributingGoal(goal);
setShowContributeModal(true);
}}
>
<i className="bi bi-plus-circle me-1"></i>
{t('goals.contribute')}
</button>
)}
<button
className="btn btn-outline-secondary btn-sm"
onClick={() => handleEdit(goal)}
>
<i className="bi bi-pencil"></i>
</button>
<div className="dropdown">
<button
className="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown"
>
<i className="bi bi-three-dots-vertical"></i>
</button>
<ul className="dropdown-menu dropdown-menu-dark dropdown-menu-end">
{goal.status === 'active' && (
<li>
<button
className="dropdown-item"
onClick={() => handleStatusChange(goal, 'paused')}
>
<i className="bi bi-pause-circle me-2"></i>
{t('goals.pause')}
</button>
</li>
)}
{goal.status === 'paused' && (
<li>
<button
className="dropdown-item"
onClick={() => handleStatusChange(goal, 'active')}
>
<i className="bi bi-play-circle me-2"></i>
{t('goals.resume')}
</button>
</li>
)}
{goal.status !== 'completed' && (
<li>
<button
className="dropdown-item text-success"
onClick={() => handleStatusChange(goal, 'completed')}
>
<i className="bi bi-check-circle me-2"></i>
{t('goals.markCompleted')}
</button>
</li>
)}
<li><hr className="dropdown-divider" /></li>
<li>
<button
className="dropdown-item text-danger"
onClick={() => setDeleteGoal(goal)}
>
<i className="bi bi-trash me-2"></i>
{t('common.delete')}
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Goal Form Modal */}
{showModal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className={`bi ${editingGoal ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
{editingGoal ? t('goals.editGoal') : t('goals.newGoal')}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => setShowModal(false)}
></button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{/* Name */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.name')} *</label>
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
required
/>
</div>
{/* Description */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.description')}</label>
<textarea
className="form-control bg-dark border-secondary text-white"
rows="2"
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
></textarea>
</div>
{/* Target Amount */}
<div className="row mb-3">
<div className="col-6">
<label className="form-label text-slate-400">{t('goals.targetAmount')} *</label>
<input
type="number"
step="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.target_amount}
onChange={(e) => setFormData({...formData, target_amount: e.target.value})}
required
/>
</div>
<div className="col-6">
<label className="form-label text-slate-400">{t('goals.currentAmount')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.current_amount}
onChange={(e) => setFormData({...formData, current_amount: e.target.value})}
/>
</div>
</div>
{/* Target Date & Monthly */}
<div className="row mb-3">
<div className="col-6">
<label className="form-label text-slate-400">{t('goals.targetDate')}</label>
<input
type="date"
className="form-control bg-dark border-secondary text-white"
value={formData.target_date}
onChange={(e) => setFormData({...formData, target_date: e.target.value})}
/>
</div>
<div className="col-6">
<label className="form-label text-slate-400">{t('goals.monthlyContribution')}</label>
<input
type="number"
step="0.01"
className="form-control bg-dark border-secondary text-white"
value={formData.monthly_contribution}
onChange={(e) => setFormData({...formData, monthly_contribution: e.target.value})}
/>
</div>
</div>
{/* Icon Selection */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.icon')}</label>
<div className="d-flex flex-wrap gap-2">
{icons.map(icon => (
<button
key={icon}
type="button"
className={`btn btn-sm ${formData.icon === icon ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setFormData({...formData, icon})}
>
<i className={`bi ${icon}`}></i>
</button>
))}
</div>
</div>
{/* Color Selection */}
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.color')}</label>
<div className="d-flex flex-wrap gap-2">
{colors.map(color => (
<button
key={color}
type="button"
className="btn btn-sm p-0"
style={{
width: '32px',
height: '32px',
background: color,
borderRadius: '50%',
border: formData.color === color ? '3px solid white' : 'none'
}}
onClick={() => setFormData({...formData, color})}
></button>
))}
</div>
</div>
</div>
<div className="modal-footer border-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setShowModal(false)}
>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-primary">
<i className="bi bi-check-lg me-1"></i>
{t('common.save')}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Contribute Modal */}
{showContributeModal && contributingGoal && (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
<div className="modal-dialog modal-dialog-centered modal-sm">
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
<div className="modal-header border-0">
<h5 className="modal-title text-white">
<i className="bi bi-plus-circle me-2"></i>
{t('goals.contribute')}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={() => setShowContributeModal(false)}
></button>
</div>
<form onSubmit={handleContribute}>
<div className="modal-body">
<div className="text-center mb-3">
<i
className={`bi ${contributingGoal.icon || 'bi-piggy-bank'} fs-1`}
style={{ color: contributingGoal.color || '#3b82f6' }}
></i>
<h6 className="text-white mt-2">{contributingGoal.name}</h6>
<small className="text-slate-400">
{currency(contributingGoal.current_amount)} de {currency(contributingGoal.target_amount)}
</small>
</div>
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.contributeAmount')} *</label>
<input
type="number"
step="0.01"
className="form-control bg-dark border-secondary text-white text-center fs-4"
value={contributeAmount}
onChange={(e) => setContributeAmount(e.target.value)}
required
autoFocus
/>
</div>
<div className="mb-3">
<label className="form-label text-slate-400">{t('goals.notes')}</label>
<input
type="text"
className="form-control bg-dark border-secondary text-white"
value={contributeNote}
onChange={(e) => setContributeNote(e.target.value)}
placeholder={t('goals.notesPlaceholder')}
/>
</div>
</div>
<div className="modal-footer border-0">
<button
type="button"
className="btn btn-outline-secondary"
onClick={() => setShowContributeModal(false)}
>
{t('common.cancel')}
</button>
<button type="submit" className="btn btn-success">
<i className="bi bi-plus-lg me-1"></i>
{t('goals.addContribution')}
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* Delete Confirmation */}
<ConfirmModal
show={!!deleteGoal}
onClose={() => setDeleteGoal(null)}
onConfirm={handleDelete}
title={t('goals.deleteGoal')}
message={t('goals.deleteConfirm', { name: deleteGoal?.name })}
confirmText={t('common.delete')}
variant="danger"
/>
</div>
);
};
export default Goals;

View File

@ -0,0 +1,834 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { reportService, categoryService } from '../services/api';
import useFormatters from '../hooks/useFormatters';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Bar, Line, Doughnut, Pie } from 'react-chartjs-2';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
const Reports = () => {
const { t } = useTranslation();
const { currency } = useFormatters();
const [activeTab, setActiveTab] = useState('summary');
const [loading, setLoading] = useState(true);
const [year, setYear] = useState(new Date().getFullYear());
const [months, setMonths] = useState(12);
// Data states
const [summary, setSummary] = useState(null);
const [categoryData, setCategoryData] = useState(null);
const [evolutionData, setEvolutionData] = useState(null);
const [dayOfWeekData, setDayOfWeekData] = useState(null);
const [topExpenses, setTopExpenses] = useState(null);
const [projection, setProjection] = useState(null);
const [comparison, setComparison] = useState(null);
// Load data based on active tab
const loadData = useCallback(async () => {
setLoading(true);
try {
switch (activeTab) {
case 'summary':
const summaryRes = await reportService.getSummary({ year });
setSummary(summaryRes);
break;
case 'category':
const catRes = await reportService.getByCategory({ type: 'debit' });
setCategoryData(catRes);
break;
case 'evolution':
const evoRes = await reportService.getMonthlyEvolution({ months });
setEvolutionData(evoRes);
break;
case 'dayOfWeek':
const dowRes = await reportService.getByDayOfWeek({ months: 6 });
setDayOfWeekData(dowRes);
break;
case 'topExpenses':
const topRes = await reportService.getTopExpenses({ limit: 20 });
setTopExpenses(topRes);
break;
case 'projection':
const projRes = await reportService.getProjection();
setProjection(projRes);
break;
case 'comparison':
const compRes = await reportService.comparePeriods();
setComparison(compRes);
break;
}
} catch (error) {
console.error('Error loading report data:', error);
} finally {
setLoading(false);
}
}, [activeTab, year, months]);
useEffect(() => {
loadData();
}, [loadData]);
const tabs = [
{ id: 'summary', label: t('reports.summary'), icon: 'bi-clipboard-data' },
{ id: 'category', label: t('reports.byCategory'), icon: 'bi-pie-chart' },
{ id: 'evolution', label: t('reports.monthlyEvolution'), icon: 'bi-graph-up' },
{ id: 'comparison', label: t('reports.comparison'), icon: 'bi-arrow-left-right' },
{ id: 'topExpenses', label: t('reports.topExpenses'), icon: 'bi-sort-down' },
{ id: 'projection', label: t('reports.projection'), icon: 'bi-lightning' },
{ id: 'dayOfWeek', label: 'Por día', icon: 'bi-calendar-week' },
];
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#94a3b8' }
}
},
scales: {
x: {
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(148, 163, 184, 0.1)' }
},
y: {
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(148, 163, 184, 0.1)' }
}
}
};
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#94a3b8', padding: 15, font: { size: 11 } }
}
}
};
// Render Summary Tab
const renderSummary = () => {
if (!summary) return null;
return (
<div className="row g-4">
{/* Year Selector */}
<div className="col-12">
<div className="d-flex gap-2 mb-3">
{[2024, 2025].map(y => (
<button
key={y}
className={`btn btn-sm ${year === y ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setYear(y)}
>
{y}
</button>
))}
</div>
</div>
{/* Summary Cards */}
<div className="col-md-4">
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #059669 0%, #047857 100%)' }}>
<div className="card-body text-white">
<h6 className="opacity-75">{t('reports.income')} {year}</h6>
<h3 className="mb-2">{currency(summary.current.income)}</h3>
{summary.variation.income !== 0 && (
<span className={`badge ${summary.variation.income >= 0 ? 'bg-success' : 'bg-danger'}`}>
<i className={`bi bi-arrow-${summary.variation.income >= 0 ? 'up' : 'down'} me-1`}></i>
{Math.abs(summary.variation.income)}% {t('reports.vsLastPeriod')}
</span>
)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)' }}>
<div className="card-body text-white">
<h6 className="opacity-75">{t('reports.expenses')} {year}</h6>
<h3 className="mb-2">{currency(summary.current.expense)}</h3>
{summary.variation.expense !== 0 && (
<span className={`badge ${summary.variation.expense <= 0 ? 'bg-success' : 'bg-danger'}`}>
<i className={`bi bi-arrow-${summary.variation.expense >= 0 ? 'up' : 'down'} me-1`}></i>
{Math.abs(summary.variation.expense)}% {t('reports.vsLastPeriod')}
</span>
)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' }}>
<div className="card-body text-white">
<h6 className="opacity-75">{t('reports.balance')} {year}</h6>
<h3 className="mb-2">{currency(summary.current.balance)}</h3>
<span className="small opacity-75">
Tasa de ahorro: {summary.current.income > 0
? ((summary.current.balance / summary.current.income) * 100).toFixed(1)
: 0}%
</span>
</div>
</div>
</div>
{/* Comparison Chart */}
<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-bar-chart me-2"></i>
Comparativa Anual
</h6>
</div>
<div className="card-body" style={{ height: '300px' }}>
<Bar
data={{
labels: ['Ingresos', 'Gastos', 'Balance'],
datasets: [
{
label: String(year - 1),
data: [summary.previous.income, summary.previous.expense, summary.previous.balance],
backgroundColor: 'rgba(148, 163, 184, 0.5)',
borderColor: '#94a3b8',
borderWidth: 1,
},
{
label: String(year),
data: [summary.current.income, summary.current.expense, summary.current.balance],
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)', 'rgba(59, 130, 246, 0.7)'],
borderColor: ['#10b981', '#ef4444', '#3b82f6'],
borderWidth: 1,
},
],
}}
options={chartOptions}
/>
</div>
</div>
</div>
</div>
);
};
// Render Category Tab
const renderCategory = () => {
if (!categoryData) return null;
const colors = categoryData.data.map((_, i) =>
`hsl(${(i * 360) / categoryData.data.length}, 70%, 50%)`
);
return (
<div className="row g-4">
<div className="col-lg-6">
<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-pie-chart me-2"></i>
Distribución de Gastos
</h6>
</div>
<div className="card-body" style={{ height: '400px' }}>
<Doughnut
data={{
labels: categoryData.data.map(c => c.category_name),
datasets: [{
data: categoryData.data.map(c => c.total),
backgroundColor: colors,
borderWidth: 0,
}],
}}
options={doughnutOptions}
/>
</div>
</div>
</div>
<div className="col-lg-6">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-header border-0 bg-transparent d-flex justify-content-between">
<h6 className="text-white mb-0">
<i className="bi bi-list-ol me-2"></i>
Detalle por Categoría
</h6>
<span className="text-success fw-bold">{currency(categoryData.total)}</span>
</div>
<div className="card-body p-0" style={{ maxHeight: '400px', overflowY: 'auto' }}>
<table className="table table-dark table-hover mb-0">
<thead className="sticky-top" style={{ background: '#1e293b' }}>
<tr>
<th>Categoría</th>
<th className="text-end">Total</th>
<th className="text-end">%</th>
</tr>
</thead>
<tbody>
{categoryData.data.map((cat, i) => (
<tr key={cat.category_id}>
<td>
<i className={`bi ${cat.icon} me-2`} style={{ color: colors[i] }}></i>
{cat.category_name}
</td>
<td className="text-end">{currency(cat.total)}</td>
<td className="text-end">
<span className="badge bg-secondary">{cat.percentage}%</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
// Render Evolution Tab
const renderEvolution = () => {
if (!evolutionData) return null;
return (
<div className="row g-4">
<div className="col-12">
<div className="d-flex gap-2 mb-3">
{[6, 12, 24].map(m => (
<button
key={m}
className={`btn btn-sm ${months === m ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setMonths(m)}
>
{m} meses
</button>
))}
</div>
</div>
{/* Averages Cards */}
<div className="col-md-3">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.avgIncome')}</small>
<h5 className="text-success mb-0">{currency(evolutionData.averages.income)}</h5>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.avgExpense')}</small>
<h5 className="text-danger mb-0">{currency(evolutionData.averages.expense)}</h5>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.balance')}</small>
<h5 className={`mb-0 ${evolutionData.averages.balance >= 0 ? 'text-success' : 'text-danger'}`}>
{currency(evolutionData.averages.balance)}
</h5>
</div>
</div>
</div>
<div className="col-md-3">
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
<div className="card-body">
<small className="text-slate-400">{t('reports.savingsRate')}</small>
<h5 className="text-primary mb-0">{evolutionData.averages.savings_rate}%</h5>
</div>
</div>
</div>
{/* Evolution Chart */}
<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-graph-up me-2"></i>
{t('reports.monthlyEvolution')}
</h6>
</div>
<div className="card-body" style={{ height: '350px' }}>
<Line
data={{
labels: evolutionData.data.map(d => d.month_label),
datasets: [
{
label: t('reports.income'),
data: evolutionData.data.map(d => d.income),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.3,
},
{
label: t('reports.expenses'),
data: evolutionData.data.map(d => d.expense),
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
fill: true,
tension: 0.3,
},
{
label: t('reports.balance'),
data: evolutionData.data.map(d => d.balance),
borderColor: '#3b82f6',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
},
],
}}
options={chartOptions}
/>
</div>
</div>
</div>
{/* Savings Rate Chart */}
<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-percent me-2"></i>
{t('reports.savingsRate')} por mes
</h6>
</div>
<div className="card-body" style={{ height: '250px' }}>
<Bar
data={{
labels: evolutionData.data.map(d => d.month_label),
datasets: [{
label: t('reports.savingsRate'),
data: evolutionData.data.map(d => d.savings_rate),
backgroundColor: evolutionData.data.map(d =>
d.savings_rate >= 20 ? 'rgba(16, 185, 129, 0.7)' :
d.savings_rate >= 10 ? 'rgba(245, 158, 11, 0.7)' :
d.savings_rate >= 0 ? 'rgba(239, 68, 68, 0.5)' :
'rgba(239, 68, 68, 0.8)'
),
borderRadius: 4,
}],
}}
options={{
...chartOptions,
plugins: {
...chartOptions.plugins,
legend: { display: false }
}
}}
/>
</div>
</div>
</div>
</div>
);
};
// Render Comparison Tab
const renderComparison = () => {
if (!comparison) return null;
return (
<div className="row g-4">
<div className="col-md-6">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-header border-0 bg-transparent">
<h6 className="text-white mb-0">{comparison.period2.label}</h6>
</div>
<div className="card-body">
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.income')}</span>
<span className="text-success">{currency(comparison.period2.income)}</span>
</div>
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.expenses')}</span>
<span className="text-danger">{currency(comparison.period2.expense)}</span>
</div>
<div className="d-flex justify-content-between">
<span className="text-slate-400">{t('reports.balance')}</span>
<span className={comparison.period2.balance >= 0 ? 'text-success' : 'text-danger'}>
{currency(comparison.period2.balance)}
</span>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0" style={{ background: '#0f172a', border: '2px solid #3b82f6' }}>
<div className="card-header border-0 bg-transparent">
<h6 className="text-white mb-0">
{comparison.period1.label}
<span className="badge bg-primary ms-2">Actual</span>
</h6>
</div>
<div className="card-body">
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.income')}</span>
<div>
<span className="text-success">{currency(comparison.period1.income)}</span>
{comparison.variation.income !== 0 && (
<span className={`badge ms-2 ${comparison.variation.income >= 0 ? 'bg-success' : 'bg-danger'}`}>
{comparison.variation.income > 0 ? '+' : ''}{comparison.variation.income}%
</span>
)}
</div>
</div>
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.expenses')}</span>
<div>
<span className="text-danger">{currency(comparison.period1.expense)}</span>
{comparison.variation.expense !== 0 && (
<span className={`badge ms-2 ${comparison.variation.expense <= 0 ? 'bg-success' : 'bg-danger'}`}>
{comparison.variation.expense > 0 ? '+' : ''}{comparison.variation.expense}%
</span>
)}
</div>
</div>
<div className="d-flex justify-content-between">
<span className="text-slate-400">{t('reports.balance')}</span>
<span className={comparison.period1.balance >= 0 ? 'text-success' : 'text-danger'}>
{currency(comparison.period1.balance)}
</span>
</div>
</div>
</div>
</div>
{/* Comparison Chart */}
<div className="col-12">
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-body" style={{ height: '300px' }}>
<Bar
data={{
labels: [t('reports.income'), t('reports.expenses'), t('reports.balance')],
datasets: [
{
label: comparison.period2.label,
data: [comparison.period2.income, comparison.period2.expense, comparison.period2.balance],
backgroundColor: 'rgba(148, 163, 184, 0.5)',
borderRadius: 4,
},
{
label: comparison.period1.label,
data: [comparison.period1.income, comparison.period1.expense, comparison.period1.balance],
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)', 'rgba(59, 130, 246, 0.7)'],
borderRadius: 4,
},
],
}}
options={chartOptions}
/>
</div>
</div>
</div>
</div>
);
};
// Render Top Expenses Tab
const renderTopExpenses = () => {
if (!topExpenses) return null;
return (
<div className="card border-0" style={{ background: '#0f172a' }}>
<div className="card-header border-0 bg-transparent d-flex justify-content-between">
<h6 className="text-white mb-0">
<i className="bi bi-sort-down me-2"></i>
Top 20 Gastos del Mes
</h6>
<span className="text-danger fw-bold">{currency(topExpenses.total)}</span>
</div>
<div className="card-body p-0">
<div className="table-responsive">
<table className="table table-dark table-hover mb-0">
<thead>
<tr>
<th>#</th>
<th>Descripción</th>
<th>Categoría</th>
<th>Fecha</th>
<th className="text-end">Monto</th>
</tr>
</thead>
<tbody>
{topExpenses.data.map((t, i) => (
<tr key={t.id}>
<td><span className="badge bg-secondary">{i + 1}</span></td>
<td className="text-truncate" style={{ maxWidth: '200px' }}>{t.description}</td>
<td><span className="badge bg-primary">{t.category || '-'}</span></td>
<td className="text-slate-400">{t.date}</td>
<td className="text-end text-danger fw-bold">{currency(t.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
// Render Projection Tab
const renderProjection = () => {
if (!projection) return null;
return (
<div className="row g-4">
<div className="col-md-6">
<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-calendar3 me-2"></i>
Mes Actual
</h6>
</div>
<div className="card-body">
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.income')}</span>
<span className="text-success">{currency(projection.current_month.income)}</span>
</div>
<div className="d-flex justify-content-between mb-3">
<span className="text-slate-400">{t('reports.expenses')}</span>
<span className="text-danger">{currency(projection.current_month.expense)}</span>
</div>
<hr className="border-secondary" />
<div className="d-flex justify-content-between">
<span className="text-slate-400">{t('reports.daysRemaining')}</span>
<span className="text-white">{projection.current_month.days_remaining} días</span>
</div>
</div>
</div>
</div>
<div className="col-md-6">
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 100%)' }}>
<div className="card-header border-0 bg-transparent">
<h6 className="text-white mb-0">
<i className="bi bi-lightning me-2"></i>
{t('reports.projectionTitle')}
</h6>
</div>
<div className="card-body text-white">
<div className="d-flex justify-content-between mb-3">
<span className="opacity-75">{t('reports.projectedIncome')}</span>
<span className="fw-bold">{currency(projection.projection.income)}</span>
</div>
<div className="d-flex justify-content-between mb-3">
<span className="opacity-75">{t('reports.projectedExpense')}</span>
<span className="fw-bold">{currency(projection.projection.expense)}</span>
</div>
<hr className="border-white opacity-25" />
<div className="d-flex justify-content-between">
<span className="opacity-75">{t('reports.balance')}</span>
<span className={`fw-bold ${projection.projection.balance >= 0 ? '' : 'text-warning'}`}>
{currency(projection.projection.balance)}
</span>
</div>
</div>
</div>
</div>
{/* vs Average */}
<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-bar-chart me-2"></i>
{t('reports.vsAverage')} (últimos 3 meses)
</h6>
</div>
<div className="card-body" style={{ height: '250px' }}>
<Bar
data={{
labels: [t('reports.income'), t('reports.expenses')],
datasets: [
{
label: 'Promedio histórico',
data: [projection.historical_average.income, projection.historical_average.expense],
backgroundColor: 'rgba(148, 163, 184, 0.5)',
borderRadius: 4,
},
{
label: 'Proyección mes',
data: [projection.projection.income, projection.projection.expense],
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)'],
borderRadius: 4,
},
],
}}
options={chartOptions}
/>
</div>
</div>
</div>
</div>
);
};
// Render Day of Week Tab
const renderDayOfWeek = () => {
if (!dayOfWeekData) return null;
const days = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
return (
<div className="row g-4">
<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-calendar-week me-2"></i>
Gastos por Día de la Semana
</h6>
</div>
<div className="card-body" style={{ height: '300px' }}>
<Bar
data={{
labels: dayOfWeekData.map(d => days[d.day_num - 1]),
datasets: [{
label: 'Total gastado',
data: dayOfWeekData.map(d => d.total),
backgroundColor: dayOfWeekData.map(d =>
d.day_num === 1 || d.day_num === 7
? 'rgba(245, 158, 11, 0.7)'
: 'rgba(59, 130, 246, 0.7)'
),
borderRadius: 4,
}],
}}
options={{
...chartOptions,
plugins: { ...chartOptions.plugins, legend: { display: false } }
}}
/>
</div>
</div>
</div>
<div className="col-12">
<div className="card border-0" style={{ background: '#1e293b' }}>
<div className="card-body p-0">
<table className="table table-dark mb-0">
<thead>
<tr>
<th>Día</th>
<th className="text-center">Transacciones</th>
<th className="text-end">Total</th>
<th className="text-end">Promedio</th>
</tr>
</thead>
<tbody>
{dayOfWeekData.map(d => (
<tr key={d.day_num}>
<td>
<i className={`bi bi-calendar3 me-2 ${d.day_num === 1 || d.day_num === 7 ? 'text-warning' : 'text-primary'}`}></i>
{d.day}
</td>
<td className="text-center"><span className="badge bg-secondary">{d.count}</span></td>
<td className="text-end text-danger">{currency(d.total)}</td>
<td className="text-end text-slate-400">{currency(d.average)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
const renderContent = () => {
if (loading) {
return (
<div className="text-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">{t('common.loading')}</span>
</div>
</div>
);
}
switch (activeTab) {
case 'summary': return renderSummary();
case 'category': return renderCategory();
case 'evolution': return renderEvolution();
case 'comparison': return renderComparison();
case 'topExpenses': return renderTopExpenses();
case 'projection': return renderProjection();
case 'dayOfWeek': return renderDayOfWeek();
default: return null;
}
};
return (
<div className="reports-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-bar-chart-line me-2"></i>
{t('reports.title')}
</h4>
<p className="text-slate-400 mb-0 small">{t('reports.subtitle')}</p>
</div>
</div>
{/* Tabs */}
<div className="card border-0 mb-4" style={{ background: '#0f172a' }}>
<div className="card-body p-2">
<div className="d-flex flex-wrap gap-2">
{tabs.map(tab => (
<button
key={tab.id}
className={`btn btn-sm ${activeTab === tab.id ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setActiveTab(tab.id)}
>
<i className={`bi ${tab.icon} me-1`}></i>
{tab.label}
</button>
))}
</div>
</div>
</div>
{/* Content */}
{renderContent()}
</div>
);
};
export default Reports;

View File

@ -1282,4 +1282,180 @@ export const campaignService = {
},
};
// ============================================
// Financial Goals (Metas Financieras)
// ============================================
export const financialGoalService = {
// Listar todas las metas
getAll: async (params = {}) => {
const response = await api.get('/financial-goals', { params });
return response.data;
},
// Obtener una meta específica
getById: async (id) => {
const response = await api.get(`/financial-goals/${id}`);
return response.data;
},
// Crear nueva meta
create: async (data) => {
const response = await api.post('/financial-goals', data);
return response.data;
},
// Actualizar meta
update: async (id, data) => {
const response = await api.put(`/financial-goals/${id}`, data);
return response.data;
},
// Eliminar meta
delete: async (id) => {
const response = await api.delete(`/financial-goals/${id}`);
return response.data;
},
// Añadir contribución
addContribution: async (goalId, data) => {
const response = await api.post(`/financial-goals/${goalId}/contributions`, data);
return response.data;
},
// Eliminar contribución
removeContribution: async (goalId, contributionId) => {
const response = await api.delete(`/financial-goals/${goalId}/contributions/${contributionId}`);
return response.data;
},
};
// ============================================
// Budgets (Presupuestos)
// ============================================
export const budgetService = {
// Listar presupuestos de un período
getAll: async (params = {}) => {
const response = await api.get('/budgets', { params });
return response.data;
},
// Obtener un presupuesto específico
getById: async (id) => {
const response = await api.get(`/budgets/${id}`);
return response.data;
},
// Crear nuevo presupuesto
create: async (data) => {
const response = await api.post('/budgets', data);
return response.data;
},
// Actualizar presupuesto
update: async (id, data) => {
const response = await api.put(`/budgets/${id}`, data);
return response.data;
},
// Eliminar presupuesto
delete: async (id) => {
const response = await api.delete(`/budgets/${id}`);
return response.data;
},
// Obtener categorías disponibles
getAvailableCategories: async (params = {}) => {
const response = await api.get('/budgets/available-categories', { params });
return response.data;
},
// Copiar al próximo mes
copyToNextMonth: async (year, month) => {
const response = await api.post('/budgets/copy-to-next-month', { year, month });
return response.data;
},
// Resumen anual
getYearSummary: async (params = {}) => {
const response = await api.get('/budgets/year-summary', { params });
return response.data;
},
};
// ============================================
// Reports (Reportes)
// ============================================
export const reportService = {
// Resumen general
getSummary: async (params = {}) => {
const response = await api.get('/reports/summary', { params });
return response.data;
},
// Por categoría
getByCategory: async (params = {}) => {
const response = await api.get('/reports/by-category', { params });
return response.data;
},
// Evolución mensual
getMonthlyEvolution: async (params = {}) => {
const response = await api.get('/reports/monthly-evolution', { params });
return response.data;
},
// Por día de la semana
getByDayOfWeek: async (params = {}) => {
const response = await api.get('/reports/by-day-of-week', { params });
return response.data;
},
// Top gastos
getTopExpenses: async (params = {}) => {
const response = await api.get('/reports/top-expenses', { params });
return response.data;
},
// Comparar períodos
comparePeriods: async (params = {}) => {
const response = await api.get('/reports/compare-periods', { params });
return response.data;
},
// Reporte de cuentas
getAccountsReport: async () => {
const response = await api.get('/reports/accounts');
return response.data;
},
// Proyección
getProjection: async (params = {}) => {
const response = await api.get('/reports/projection', { params });
return response.data;
},
// Reporte de recurrentes
getRecurringReport: async () => {
const response = await api.get('/reports/recurring');
return response.data;
},
};
// ============================================
// Financial Health (Salud Financiera)
// ============================================
export const financialHealthService = {
// Obtener salud financiera completa
get: async () => {
const response = await api.get('/financial-health');
return response.data;
},
// Historial de puntuación
getHistory: async (params = {}) => {
const response = await api.get('/financial-health/history', { params });
return response.data;
},
};
export default api;