From 854e90e23ce711600888a8313618895b0cb18c3f Mon Sep 17 00:00:00 2001 From: marcoitaloesp-ai Date: Sun, 14 Dec 2025 16:31:45 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 77 ++ VERSION | 2 +- .../Http/Controllers/Api/BudgetController.php | 241 +++++ .../Api/FinancialGoalController.php | 198 +++++ .../Api/FinancialHealthController.php | 498 +++++++++++ .../Http/Controllers/Api/ReportController.php | 453 ++++++++++ backend/app/Models/Budget.php | 164 ++++ backend/app/Models/FinancialGoal.php | 169 ++++ backend/app/Models/GoalContribution.php | 38 + ...14_161644_create_financial_goals_table.php | 57 ++ ...2025_12_14_161655_create_budgets_table.php | 40 + backend/routes/api.php | 40 + frontend/src/App.jsx | 44 + frontend/src/components/Layout.jsx | 13 + frontend/src/i18n/locales/es.json | 179 +++- frontend/src/pages/Budgets.jsx | 594 +++++++++++++ frontend/src/pages/FinancialHealth.jsx | 418 +++++++++ frontend/src/pages/Goals.jsx | 657 ++++++++++++++ frontend/src/pages/Reports.jsx | 834 ++++++++++++++++++ frontend/src/services/api.js | 176 ++++ 20 files changed, 4890 insertions(+), 2 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/BudgetController.php create mode 100644 backend/app/Http/Controllers/Api/FinancialGoalController.php create mode 100644 backend/app/Http/Controllers/Api/FinancialHealthController.php create mode 100644 backend/app/Http/Controllers/Api/ReportController.php create mode 100644 backend/app/Models/Budget.php create mode 100644 backend/app/Models/FinancialGoal.php create mode 100644 backend/app/Models/GoalContribution.php create mode 100644 backend/database/migrations/2025_12_14_161644_create_financial_goals_table.php create mode 100644 backend/database/migrations/2025_12_14_161655_create_budgets_table.php create mode 100644 frontend/src/pages/Budgets.jsx create mode 100644 frontend/src/pages/FinancialHealth.jsx create mode 100644 frontend/src/pages/Goals.jsx create mode 100644 frontend/src/pages/Reports.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 397164b..4779a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 3492b09..359c410 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.31.2 +1.32.0 diff --git a/backend/app/Http/Controllers/Api/BudgetController.php b/backend/app/Http/Controllers/Api/BudgetController.php new file mode 100644 index 0000000..ce5954f --- /dev/null +++ b/backend/app/Http/Controllers/Api/BudgetController.php @@ -0,0 +1,241 @@ +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')), + ], + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/FinancialGoalController.php b/backend/app/Http/Controllers/Api/FinancialGoalController.php new file mode 100644 index 0000000..0375706 --- /dev/null +++ b/backend/app/Http/Controllers/Api/FinancialGoalController.php @@ -0,0 +1,198 @@ +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'), + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/FinancialHealthController.php b/backend/app/Http/Controllers/Api/FinancialHealthController.php new file mode 100644 index 0000000..f148ca7 --- /dev/null +++ b/backend/app/Http/Controllers/Api/FinancialHealthController.php @@ -0,0 +1,498 @@ +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); + } +} diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 0000000..c399561 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,453 @@ +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, + ], + ]); + } +} diff --git a/backend/app/Models/Budget.php b/backend/app/Models/Budget.php new file mode 100644 index 0000000..2e4a9ab --- /dev/null +++ b/backend/app/Models/Budget.php @@ -0,0 +1,164 @@ + '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); + } +} diff --git a/backend/app/Models/FinancialGoal.php b/backend/app/Models/FinancialGoal.php new file mode 100644 index 0000000..cfe7750 --- /dev/null +++ b/backend/app/Models/FinancialGoal.php @@ -0,0 +1,169 @@ + '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); + } +} diff --git a/backend/app/Models/GoalContribution.php b/backend/app/Models/GoalContribution.php new file mode 100644 index 0000000..a4e8329 --- /dev/null +++ b/backend/app/Models/GoalContribution.php @@ -0,0 +1,38 @@ + '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); + } +} diff --git a/backend/database/migrations/2025_12_14_161644_create_financial_goals_table.php b/backend/database/migrations/2025_12_14_161644_create_financial_goals_table.php new file mode 100644 index 0000000..921fc26 --- /dev/null +++ b/backend/database/migrations/2025_12_14_161644_create_financial_goals_table.php @@ -0,0 +1,57 @@ +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'); + } +}; diff --git a/backend/database/migrations/2025_12_14_161655_create_budgets_table.php b/backend/database/migrations/2025_12_14_161655_create_budgets_table.php new file mode 100644 index 0000000..4054511 --- /dev/null +++ b/backend/database/migrations/2025_12_14_161655_create_budgets_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 986ac2f..0e754c4 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -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']); }); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 58b5cb2..c9b7fe4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index e1b5eeb..31ececf 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -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', diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 60c5b34..c827f18 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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" } } + diff --git a/frontend/src/pages/Budgets.jsx b/frontend/src/pages/Budgets.jsx new file mode 100644 index 0000000..da8d0b0 --- /dev/null +++ b/frontend/src/pages/Budgets.jsx @@ -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 ( +
+
+ {t('common.loading')} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + {t('budgets.title')} +

+

{t('budgets.subtitle')}

+
+
+ + +
+
+ + {/* Month/Year Selector */} +
+
+
+
+ +
+
+ + +
+
+ +
+
+
+
+ + {/* Summary Cards */} +
+
+
+
+ {t('budgets.totalBudgeted')} +

{currency(totals.budgeted)}

+
+
+
+
+
+
+ {t('budgets.totalSpent')} +

{currency(totals.spent)}

+
+
+
+
+
= 0 + ? 'linear-gradient(135deg, #10b981 0%, #059669 100%)' + : 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)' + }} + > +
+ {t('budgets.remaining')} +

{currency(totals.remaining)}

+
+
+
+
+
+
+ {t('budgets.usage')} +

+ {totals.percentage.toFixed(1)}% +

+
+
+
+
+
+
+
+ + {/* Budgets List */} + {budgets.length === 0 ? ( +
+
+ +
{t('budgets.noBudgets')}
+

{t('budgets.noBudgetsDescription')}

+ +
+
+ ) : ( +
+ {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 ( +
+
+
+ {/* Header */} +
+
+
+ +
+
+
{budget.category?.name || 'Sin categoría'}
+
+
+
+ +
    +
  • + +
  • +
  • + +
  • +
+
+
+ + {/* Progress */} +
+
+ {t('budgets.spent')} + {t('budgets.budgeted')} +
+
+ + {currency(spent)} + + {currency(amount)} +
+
+
+
+
+ + {/* Stats */} +
+
+ {t('budgets.remaining')} + = 0 ? 'text-success' : 'text-danger'}`}> + {currency(remaining)} + +
+
+ {t('budgets.usage')} + + {percentage.toFixed(1)}% + +
+
+ + {/* Warning */} + {isExceeded && ( +
+ + + {t('budgets.exceeded')} {currency(Math.abs(remaining))} + +
+ )} + {!isExceeded && percentage >= 80 && ( +
+ + + {t('budgets.almostExceeded')} + +
+ )} +
+
+
+ ); + })} +
+ )} + + {/* Year Summary */} + {yearSummary && yearSummary.length > 0 && ( +
+
+ + {t('budgets.yearSummary')} {year} +
+
+
+
+ + + + + + + + + + + + {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 ( + setMonth(item.month)} + > + + + + + + + ); + })} + +
{t('budgets.month')}{t('budgets.budgeted')}{t('budgets.spent')}{t('budgets.remaining')}{t('budgets.usage')}
+ {monthName} + {isCurrentMonth && ( + Actual + )} + {currency(item.budgeted)}{currency(item.spent)}= 0 ? 'text-success' : 'text-danger'}`}> + {currency(item.remaining)} + + + {item.percentage.toFixed(1)}% + +
+
+
+
+
+ )} + + {/* Budget Form Modal */} + {showModal && ( +
+
+
+
+
+ + {editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')} +
+ +
+
+
+

+ {months.find(m => m.value === month)?.label} {year} +

+ + {/* Category */} +
+ + +
+ + {/* Amount */} +
+ +
+ + setFormData({...formData, amount: e.target.value})} + required + /> +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Delete Confirmation */} + setDeleteBudget(null)} + onConfirm={handleDelete} + title={t('budgets.deleteBudget')} + message={t('budgets.deleteConfirm', { category: deleteBudget?.category?.name })} + confirmText={t('common.delete')} + variant="danger" + /> +
+ ); +}; + +export default Budgets; diff --git a/frontend/src/pages/FinancialHealth.jsx b/frontend/src/pages/FinancialHealth.jsx new file mode 100644 index 0000000..2ae8ff8 --- /dev/null +++ b/frontend/src/pages/FinancialHealth.jsx @@ -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 ( +
+
+ {t('common.loading')} +
+
+ ); + } + + if (!data) { + return ( +
+ + No se pudo cargar la información de salud financiera. +
+ ); + } + + const score = data.score; + const scoreColor = getScoreColor(score); + + return ( +
+ {/* Header */} +
+
+

+ + {t('financialHealth.title')} +

+

{t('financialHealth.subtitle')}

+
+ +
+ +
+ {/* Score Circle */} +
+
+
+ {/* Score Ring */} +
+ + {/* Background circle */} + + {/* Progress circle */} + + {/* Score text */} + + {score} + + + de 100 + + +
+ +
{getScoreLabel(score)}
+

+ {t('financialHealth.scoreDescription')} +

+ + {/* History Chart */} + {history.length > 0 && ( +
+ 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 }, + }, + }} + /> +
+ )} +
+
+
+ + {/* Metrics Grid */} +
+
+ {Object.entries(data.metrics).map(([key, metric]) => { + const config = metricConfigs[key]; + if (!config) return null; + + return ( +
+
+
+
+ + + {t(`financialHealth.metrics.${key}`)} + +
+

{metric.score}/100

+ + {/* Progress bar */} +
+
+
+ + {/* Value if available */} + {metric.value !== undefined && ( + + {typeof metric.value === 'number' && key !== 'emergencyFund' + ? `${metric.value}%` + : key === 'emergencyFund' + ? `${metric.value} meses` + : metric.value + } + + )} +
+
+
+ ); + })} +
+
+ + {/* Insights */} +
+
+
+
+ + {t('financialHealth.insights')} +
+
+
+
+ {data.insights && data.insights.map((insight, index) => ( +
+
+ +
+

{insight.message}

+
+
+
+ ))} +
+
+
+
+ + {/* Recommendations */} + {data.recommendations && data.recommendations.length > 0 && ( +
+
+
+
+ + {t('financialHealth.recommendations')} +
+
+
+
+ {data.recommendations.map((rec, index) => ( + + + {rec} + + ))} +
+
+
+
+ )} + + {/* Quick Stats */} +
+
+
+
+
+ {t('financialHealth.totalBalance')} +
= 0 ? 'text-success' : 'text-danger'}`}> + {currency(data.totals?.balance || 0)} +
+
+
+
+
+
+
+ {t('financialHealth.monthlyIncome')} +
{currency(data.totals?.income || 0)}
+
+
+
+
+
+
+ {t('financialHealth.monthlyExpenses')} +
{currency(data.totals?.expense || 0)}
+
+
+
+
+
+
+ {t('financialHealth.savingsRate')} +
{data.totals?.savings_rate || 0}%
+
+
+
+
+
+
+
+ ); +}; + +export default FinancialHealth; diff --git a/frontend/src/pages/Goals.jsx b/frontend/src/pages/Goals.jsx new file mode 100644 index 0000000..55237ed --- /dev/null +++ b/frontend/src/pages/Goals.jsx @@ -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 {config.label}; + }; + + // 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 ( +
+
+ {t('common.loading')} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + {t('goals.title')} +

+

{t('goals.subtitle')}

+
+ +
+ + {/* Stats Cards */} +
+
+
+
+ {t('goals.totalGoals')} +

{stats.total}

+
+
+
+
+
+
+ {t('goals.activeGoals')} +

{stats.active}

+
+
+
+
+
+
+ {t('goals.totalSaved')} +

{currency(stats.totalCurrent)}

+
+
+
+
+
+
+ {t('goals.remaining')} +

{currency(stats.totalTarget - stats.totalCurrent)}

+
+
+
+
+ + {/* Goals Grid */} + {goals.length === 0 ? ( +
+
+ +
{t('goals.noGoals')}
+

{t('goals.noGoalsDescription')}

+ +
+
+ ) : ( +
+ {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 ( +
+
+
+ {/* Header */} +
+
+
+ +
+
+
{goal.name}
+ {goal.description && ( + {goal.description} + )} +
+
+ {getStatusBadge(goal.status)} +
+ + {/* Progress */} +
+
+ {currency(goal.current_amount)} + {currency(goal.target_amount)} +
+
+
+
+
+ {progress.toFixed(1)}% + + {t('goals.remaining')}: {currency(remaining)} + +
+
+ + {/* Info */} +
+ {goal.target_date && ( +
+ {t('goals.targetDate')} + {formatDate(goal.target_date)} +
+ )} + {goal.monthly_contribution > 0 && ( +
+ {t('goals.monthlyContribution')} + {currency(goal.monthly_contribution)} +
+ )} + {goal.months_remaining > 0 && ( +
+ {t('goals.monthsRemaining')} + {goal.months_remaining} {t('goals.months')} +
+ )} +
+ + {/* On Track Indicator */} + {goal.status === 'active' && goal.is_on_track !== undefined && ( +
+ + + {goal.is_on_track + ? t('goals.onTrack') + : t('goals.needsMore', { amount: currency(goal.required_monthly_saving || 0) }) + } + +
+ )} + + {/* Actions */} +
+ {goal.status === 'active' && ( + + )} + +
+ +
    + {goal.status === 'active' && ( +
  • + +
  • + )} + {goal.status === 'paused' && ( +
  • + +
  • + )} + {goal.status !== 'completed' && ( +
  • + +
  • + )} +

  • +
  • + +
  • +
+
+
+
+
+
+ ); + })} +
+ )} + + {/* Goal Form Modal */} + {showModal && ( +
+
+
+
+
+ + {editingGoal ? t('goals.editGoal') : t('goals.newGoal')} +
+ +
+
+
+ {/* Name */} +
+ + setFormData({...formData, name: e.target.value})} + required + /> +
+ + {/* Description */} +
+ + +
+ + {/* Target Amount */} +
+
+ + setFormData({...formData, target_amount: e.target.value})} + required + /> +
+
+ + setFormData({...formData, current_amount: e.target.value})} + /> +
+
+ + {/* Target Date & Monthly */} +
+
+ + setFormData({...formData, target_date: e.target.value})} + /> +
+
+ + setFormData({...formData, monthly_contribution: e.target.value})} + /> +
+
+ + {/* Icon Selection */} +
+ +
+ {icons.map(icon => ( + + ))} +
+
+ + {/* Color Selection */} +
+ +
+ {colors.map(color => ( + + ))} +
+
+
+
+ + +
+
+
+
+
+ )} + + {/* Contribute Modal */} + {showContributeModal && contributingGoal && ( +
+
+
+
+
+ + {t('goals.contribute')} +
+ +
+
+
+
+ +
{contributingGoal.name}
+ + {currency(contributingGoal.current_amount)} de {currency(contributingGoal.target_amount)} + +
+ +
+ + setContributeAmount(e.target.value)} + required + autoFocus + /> +
+ +
+ + setContributeNote(e.target.value)} + placeholder={t('goals.notesPlaceholder')} + /> +
+
+
+ + +
+
+
+
+
+ )} + + {/* Delete Confirmation */} + setDeleteGoal(null)} + onConfirm={handleDelete} + title={t('goals.deleteGoal')} + message={t('goals.deleteConfirm', { name: deleteGoal?.name })} + confirmText={t('common.delete')} + variant="danger" + /> +
+ ); +}; + +export default Goals; diff --git a/frontend/src/pages/Reports.jsx b/frontend/src/pages/Reports.jsx new file mode 100644 index 0000000..5f56b68 --- /dev/null +++ b/frontend/src/pages/Reports.jsx @@ -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 ( +
+ {/* Year Selector */} +
+
+ {[2024, 2025].map(y => ( + + ))} +
+
+ + {/* Summary Cards */} +
+
+
+
{t('reports.income')} {year}
+

{currency(summary.current.income)}

+ {summary.variation.income !== 0 && ( + = 0 ? 'bg-success' : 'bg-danger'}`}> + = 0 ? 'up' : 'down'} me-1`}> + {Math.abs(summary.variation.income)}% {t('reports.vsLastPeriod')} + + )} +
+
+
+ +
+
+
+
{t('reports.expenses')} {year}
+

{currency(summary.current.expense)}

+ {summary.variation.expense !== 0 && ( + + = 0 ? 'up' : 'down'} me-1`}> + {Math.abs(summary.variation.expense)}% {t('reports.vsLastPeriod')} + + )} +
+
+
+ +
+
+
+
{t('reports.balance')} {year}
+

{currency(summary.current.balance)}

+ + Tasa de ahorro: {summary.current.income > 0 + ? ((summary.current.balance / summary.current.income) * 100).toFixed(1) + : 0}% + +
+
+
+ + {/* Comparison Chart */} +
+
+
+
+ + Comparativa Anual +
+
+
+ +
+
+
+
+ ); + }; + + // Render Category Tab + const renderCategory = () => { + if (!categoryData) return null; + + const colors = categoryData.data.map((_, i) => + `hsl(${(i * 360) / categoryData.data.length}, 70%, 50%)` + ); + + return ( +
+
+
+
+
+ + Distribución de Gastos +
+
+
+ c.category_name), + datasets: [{ + data: categoryData.data.map(c => c.total), + backgroundColor: colors, + borderWidth: 0, + }], + }} + options={doughnutOptions} + /> +
+
+
+ +
+
+
+
+ + Detalle por Categoría +
+ {currency(categoryData.total)} +
+
+ + + + + + + + + + {categoryData.data.map((cat, i) => ( + + + + + + ))} + +
CategoríaTotal%
+ + {cat.category_name} + {currency(cat.total)} + {cat.percentage}% +
+
+
+
+
+ ); + }; + + // Render Evolution Tab + const renderEvolution = () => { + if (!evolutionData) return null; + + return ( +
+
+
+ {[6, 12, 24].map(m => ( + + ))} +
+
+ + {/* Averages Cards */} +
+
+
+ {t('reports.avgIncome')} +
{currency(evolutionData.averages.income)}
+
+
+
+
+
+
+ {t('reports.avgExpense')} +
{currency(evolutionData.averages.expense)}
+
+
+
+
+
+
+ {t('reports.balance')} +
= 0 ? 'text-success' : 'text-danger'}`}> + {currency(evolutionData.averages.balance)} +
+
+
+
+
+
+
+ {t('reports.savingsRate')} +
{evolutionData.averages.savings_rate}%
+
+
+
+ + {/* Evolution Chart */} +
+
+
+
+ + {t('reports.monthlyEvolution')} +
+
+
+ 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} + /> +
+
+
+ + {/* Savings Rate Chart */} +
+
+
+
+ + {t('reports.savingsRate')} por mes +
+
+
+ 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 } + } + }} + /> +
+
+
+
+ ); + }; + + // Render Comparison Tab + const renderComparison = () => { + if (!comparison) return null; + + return ( +
+
+
+
+
{comparison.period2.label}
+
+
+
+ {t('reports.income')} + {currency(comparison.period2.income)} +
+
+ {t('reports.expenses')} + {currency(comparison.period2.expense)} +
+
+ {t('reports.balance')} + = 0 ? 'text-success' : 'text-danger'}> + {currency(comparison.period2.balance)} + +
+
+
+
+ +
+
+
+
+ {comparison.period1.label} + Actual +
+
+
+
+ {t('reports.income')} +
+ {currency(comparison.period1.income)} + {comparison.variation.income !== 0 && ( + = 0 ? 'bg-success' : 'bg-danger'}`}> + {comparison.variation.income > 0 ? '+' : ''}{comparison.variation.income}% + + )} +
+
+
+ {t('reports.expenses')} +
+ {currency(comparison.period1.expense)} + {comparison.variation.expense !== 0 && ( + + {comparison.variation.expense > 0 ? '+' : ''}{comparison.variation.expense}% + + )} +
+
+
+ {t('reports.balance')} + = 0 ? 'text-success' : 'text-danger'}> + {currency(comparison.period1.balance)} + +
+
+
+
+ + {/* Comparison Chart */} +
+
+
+ +
+
+
+
+ ); + }; + + // Render Top Expenses Tab + const renderTopExpenses = () => { + if (!topExpenses) return null; + + return ( +
+
+
+ + Top 20 Gastos del Mes +
+ {currency(topExpenses.total)} +
+
+
+ + + + + + + + + + + + {topExpenses.data.map((t, i) => ( + + + + + + + + ))} + +
#DescripciónCategoríaFechaMonto
{i + 1}{t.description}{t.category || '-'}{t.date}{currency(t.amount)}
+
+
+
+ ); + }; + + // Render Projection Tab + const renderProjection = () => { + if (!projection) return null; + + return ( +
+
+
+
+
+ + Mes Actual +
+
+
+
+ {t('reports.income')} + {currency(projection.current_month.income)} +
+
+ {t('reports.expenses')} + {currency(projection.current_month.expense)} +
+
+
+ {t('reports.daysRemaining')} + {projection.current_month.days_remaining} días +
+
+
+
+ +
+
+
+
+ + {t('reports.projectionTitle')} +
+
+
+
+ {t('reports.projectedIncome')} + {currency(projection.projection.income)} +
+
+ {t('reports.projectedExpense')} + {currency(projection.projection.expense)} +
+
+
+ {t('reports.balance')} + = 0 ? '' : 'text-warning'}`}> + {currency(projection.projection.balance)} + +
+
+
+
+ + {/* vs Average */} +
+
+
+
+ + {t('reports.vsAverage')} (últimos 3 meses) +
+
+
+ +
+
+
+
+ ); + }; + + // Render Day of Week Tab + const renderDayOfWeek = () => { + if (!dayOfWeekData) return null; + + const days = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb']; + + return ( +
+
+
+
+
+ + Gastos por Día de la Semana +
+
+
+ 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 } } + }} + /> +
+
+
+ +
+
+
+ + + + + + + + + + + {dayOfWeekData.map(d => ( + + + + + + + ))} + +
DíaTransaccionesTotalPromedio
+ + {d.day} + {d.count}{currency(d.total)}{currency(d.average)}
+
+
+
+
+ ); + }; + + const renderContent = () => { + if (loading) { + return ( +
+
+ {t('common.loading')} +
+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+

+ + {t('reports.title')} +

+

{t('reports.subtitle')}

+
+
+ + {/* Tabs */} +
+
+
+ {tabs.map(tab => ( + + ))} +
+
+
+ + {/* Content */} + {renderContent()} +
+ ); +}; + +export default Reports; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 861078e..72838c0 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -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;