v1.32.0 - Financial Planning Suite: Health Score, Goals, Budgets & Reports
NEW FEATURES: - Financial Health: Score 0-100, 6 metrics, insights, recommendations - Goals: Create/edit savings goals, contributions, progress tracking - Budgets: Monthly category limits, usage alerts, year summary - Reports: 7 tabs with charts (category, evolution, projection, etc.) BACKEND: - New models: FinancialGoal, GoalContribution, Budget - New controllers: FinancialHealthController, FinancialGoalController, BudgetController, ReportController - New migrations: financial_goals, goal_contributions, budgets FRONTEND: - New pages: FinancialHealth.jsx, Goals.jsx, Budgets.jsx, Reports.jsx - New services: financialHealthService, financialGoalService, budgetService, reportService - Navigation: New 'Planning' group in sidebar Chart.js integration for all visualizations
This commit is contained in:
parent
10d2f81649
commit
854e90e23c
77
CHANGELOG.md
77
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/).
|
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
|
## [1.31.2] - 2025-12-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
241
backend/app/Http/Controllers/Api/BudgetController.php
Normal file
241
backend/app/Http/Controllers/Api/BudgetController.php
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Budget;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class BudgetController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Listar presupuestos de un período
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$year = $request->get('year', now()->year);
|
||||||
|
$month = $request->get('month', now()->month);
|
||||||
|
|
||||||
|
$budgets = Budget::forUser(Auth::id())
|
||||||
|
->forPeriod($year, $month)
|
||||||
|
->active()
|
||||||
|
->with('category')
|
||||||
|
->orderBy('amount', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
$totalBudget = $budgets->sum('amount');
|
||||||
|
$totalSpent = $budgets->sum('spent_amount');
|
||||||
|
$totalRemaining = $totalBudget - $totalSpent;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $budgets,
|
||||||
|
'summary' => [
|
||||||
|
'total_budget' => $totalBudget,
|
||||||
|
'total_spent' => $totalSpent,
|
||||||
|
'total_remaining' => $totalRemaining,
|
||||||
|
'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'period_label' => Carbon::createFromDate($year, $month, 1)->format('F Y'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear un presupuesto
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'category_id' => 'nullable|exists:categories,id',
|
||||||
|
'name' => 'nullable|string|max:255',
|
||||||
|
'amount' => 'required|numeric|min:0.01',
|
||||||
|
'currency' => 'nullable|string|size:3',
|
||||||
|
'year' => 'required|integer|min:2020|max:2100',
|
||||||
|
'month' => 'required|integer|min:1|max:12',
|
||||||
|
'period_type' => 'nullable|in:monthly,yearly',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verificar que no exista ya
|
||||||
|
$exists = Budget::forUser(Auth::id())
|
||||||
|
->where('category_id', $validated['category_id'])
|
||||||
|
->where('year', $validated['year'])
|
||||||
|
->where('month', $validated['month'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Ya existe un presupuesto para esta categoría en este período',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['user_id'] = Auth::id();
|
||||||
|
|
||||||
|
$budget = Budget::create($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Presupuesto creado',
|
||||||
|
'data' => $budget->load('category'),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ver un presupuesto
|
||||||
|
*/
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$budget = Budget::forUser(Auth::id())
|
||||||
|
->with('category')
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
// Obtener transacciones del período
|
||||||
|
$query = Transaction::where('user_id', Auth::id())
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->whereYear('transaction_date', $budget->year)
|
||||||
|
->whereMonth('transaction_date', $budget->month);
|
||||||
|
|
||||||
|
if ($budget->category_id) {
|
||||||
|
$categoryIds = [$budget->category_id];
|
||||||
|
$subcategories = Category::where('parent_id', $budget->category_id)->pluck('id')->toArray();
|
||||||
|
$categoryIds = array_merge($categoryIds, $subcategories);
|
||||||
|
$query->whereIn('category_id', $categoryIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $query->with('category')
|
||||||
|
->orderBy('transaction_date', 'desc')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'budget' => $budget,
|
||||||
|
'transactions' => $transactions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar un presupuesto
|
||||||
|
*/
|
||||||
|
public function update(Request $request, $id)
|
||||||
|
{
|
||||||
|
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'amount' => 'sometimes|numeric|min:0.01',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'is_active' => 'sometimes|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$budget->update($validated);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Presupuesto actualizado',
|
||||||
|
'data' => $budget->fresh('category'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar un presupuesto
|
||||||
|
*/
|
||||||
|
public function destroy($id)
|
||||||
|
{
|
||||||
|
$budget = Budget::forUser(Auth::id())->findOrFail($id);
|
||||||
|
$budget->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Presupuesto eliminado',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copiar presupuestos al siguiente mes
|
||||||
|
*/
|
||||||
|
public function copyToNextMonth(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'year' => 'required|integer',
|
||||||
|
'month' => 'required|integer|min:1|max:12',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Budget::copyToNextMonth(Auth::id(), $validated['year'], $validated['month']);
|
||||||
|
|
||||||
|
$nextMonth = $validated['month'] === 12 ? 1 : $validated['month'] + 1;
|
||||||
|
$nextYear = $validated['month'] === 12 ? $validated['year'] + 1 : $validated['year'];
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Presupuestos copiados',
|
||||||
|
'next_period' => [
|
||||||
|
'year' => $nextYear,
|
||||||
|
'month' => $nextMonth,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener categorías disponibles para presupuesto
|
||||||
|
*/
|
||||||
|
public function availableCategories(Request $request)
|
||||||
|
{
|
||||||
|
$year = $request->get('year', now()->year);
|
||||||
|
$month = $request->get('month', now()->month);
|
||||||
|
|
||||||
|
// Obtener categorías de gasto (debit) del usuario
|
||||||
|
$usedCategoryIds = Budget::forUser(Auth::id())
|
||||||
|
->forPeriod($year, $month)
|
||||||
|
->pluck('category_id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Categorías padre con tipo debit
|
||||||
|
$categories = Category::where('user_id', Auth::id())
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->where('type', 'debit')
|
||||||
|
->whereNotIn('id', $usedCategoryIds)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumen anual de presupuestos
|
||||||
|
*/
|
||||||
|
public function yearSummary(Request $request)
|
||||||
|
{
|
||||||
|
$year = $request->get('year', now()->year);
|
||||||
|
|
||||||
|
$monthlyData = [];
|
||||||
|
|
||||||
|
for ($month = 1; $month <= 12; $month++) {
|
||||||
|
$budgets = Budget::forUser(Auth::id())
|
||||||
|
->forPeriod($year, $month)
|
||||||
|
->active()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalBudget = $budgets->sum('amount');
|
||||||
|
$totalSpent = $budgets->sum('spent_amount');
|
||||||
|
|
||||||
|
$monthlyData[] = [
|
||||||
|
'month' => $month,
|
||||||
|
'month_name' => Carbon::createFromDate($year, $month, 1)->format('M'),
|
||||||
|
'budget' => $totalBudget,
|
||||||
|
'spent' => $totalSpent,
|
||||||
|
'remaining' => $totalBudget - $totalSpent,
|
||||||
|
'usage_percentage' => $totalBudget > 0 ? round(($totalSpent / $totalBudget) * 100, 1) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'year' => $year,
|
||||||
|
'monthly' => $monthlyData,
|
||||||
|
'totals' => [
|
||||||
|
'budget' => array_sum(array_column($monthlyData, 'budget')),
|
||||||
|
'spent' => array_sum(array_column($monthlyData, 'spent')),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
backend/app/Http/Controllers/Api/FinancialGoalController.php
Normal file
198
backend/app/Http/Controllers/Api/FinancialGoalController.php
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\FinancialGoal;
|
||||||
|
use App\Models\GoalContribution;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class FinancialGoalController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Listar todas las metas del usuario
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$query = FinancialGoal::forUser(Auth::id())
|
||||||
|
->with('contributions')
|
||||||
|
->orderByRaw("FIELD(status, 'active', 'paused', 'completed', 'cancelled')")
|
||||||
|
->orderBy('priority', 'desc')
|
||||||
|
->orderBy('target_date', 'asc');
|
||||||
|
|
||||||
|
if ($request->has('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$goals = $query->get();
|
||||||
|
|
||||||
|
// Calcular estadísticas generales
|
||||||
|
$activeGoals = $goals->where('status', 'active');
|
||||||
|
$stats = [
|
||||||
|
'total_goals' => $goals->count(),
|
||||||
|
'active_goals' => $activeGoals->count(),
|
||||||
|
'completed_goals' => $goals->where('status', 'completed')->count(),
|
||||||
|
'total_target' => $activeGoals->sum('target_amount'),
|
||||||
|
'total_saved' => $activeGoals->sum('current_amount'),
|
||||||
|
'overall_progress' => $activeGoals->sum('target_amount') > 0
|
||||||
|
? round(($activeGoals->sum('current_amount') / $activeGoals->sum('target_amount')) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $goals,
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear una nueva meta
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'icon' => 'nullable|string|max:50',
|
||||||
|
'color' => 'nullable|string|max:20',
|
||||||
|
'target_amount' => 'required|numeric|min:0.01',
|
||||||
|
'current_amount' => 'nullable|numeric|min:0',
|
||||||
|
'currency' => 'nullable|string|size:3',
|
||||||
|
'target_date' => 'nullable|date|after:today',
|
||||||
|
'start_date' => 'nullable|date',
|
||||||
|
'monthly_contribution' => 'nullable|numeric|min:0',
|
||||||
|
'priority' => 'nullable|in:high,medium,low',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$validated['user_id'] = Auth::id();
|
||||||
|
$validated['start_date'] = $validated['start_date'] ?? now()->format('Y-m-d');
|
||||||
|
|
||||||
|
$goal = FinancialGoal::create($validated);
|
||||||
|
|
||||||
|
// Si tiene monto inicial, crear contribución
|
||||||
|
if (isset($validated['current_amount']) && $validated['current_amount'] > 0) {
|
||||||
|
$goal->contributions()->create([
|
||||||
|
'amount' => $validated['current_amount'],
|
||||||
|
'contribution_date' => $validated['start_date'],
|
||||||
|
'notes' => 'Monto inicial',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Meta creada con éxito',
|
||||||
|
'data' => $goal->fresh('contributions'),
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ver una meta específica
|
||||||
|
*/
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$goal = FinancialGoal::forUser(Auth::id())
|
||||||
|
->with(['contributions' => function($q) {
|
||||||
|
$q->orderBy('contribution_date', 'desc');
|
||||||
|
}])
|
||||||
|
->findOrFail($id);
|
||||||
|
|
||||||
|
return response()->json($goal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualizar una meta
|
||||||
|
*/
|
||||||
|
public function update(Request $request, $id)
|
||||||
|
{
|
||||||
|
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'sometimes|string|max:255',
|
||||||
|
'description' => 'nullable|string',
|
||||||
|
'icon' => 'nullable|string|max:50',
|
||||||
|
'color' => 'nullable|string|max:20',
|
||||||
|
'target_amount' => 'sometimes|numeric|min:0.01',
|
||||||
|
'target_date' => 'nullable|date',
|
||||||
|
'monthly_contribution' => 'nullable|numeric|min:0',
|
||||||
|
'status' => 'sometimes|in:active,completed,paused,cancelled',
|
||||||
|
'priority' => 'sometimes|in:high,medium,low',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$goal->update($validated);
|
||||||
|
|
||||||
|
// Si se marca como completada manualmente
|
||||||
|
if (isset($validated['status']) && $validated['status'] === 'completed' && !$goal->completed_at) {
|
||||||
|
$goal->completed_at = now();
|
||||||
|
$goal->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Meta actualizada',
|
||||||
|
'data' => $goal->fresh('contributions'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar una meta
|
||||||
|
*/
|
||||||
|
public function destroy($id)
|
||||||
|
{
|
||||||
|
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
|
||||||
|
$goal->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Meta eliminada',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Añadir contribución a una meta
|
||||||
|
*/
|
||||||
|
public function addContribution(Request $request, $id)
|
||||||
|
{
|
||||||
|
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'amount' => 'required|numeric|min:0.01',
|
||||||
|
'contribution_date' => 'nullable|date',
|
||||||
|
'transaction_id' => 'nullable|exists:transactions,id',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contribution = $goal->addContribution(
|
||||||
|
$validated['amount'],
|
||||||
|
$validated['contribution_date'] ?? now(),
|
||||||
|
$validated['transaction_id'] ?? null,
|
||||||
|
$validated['notes'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Contribución añadida',
|
||||||
|
'data' => $goal->fresh('contributions'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eliminar contribución
|
||||||
|
*/
|
||||||
|
public function removeContribution($goalId, $contributionId)
|
||||||
|
{
|
||||||
|
$goal = FinancialGoal::forUser(Auth::id())->findOrFail($goalId);
|
||||||
|
$contribution = $goal->contributions()->findOrFail($contributionId);
|
||||||
|
|
||||||
|
// Restar el monto de la meta
|
||||||
|
$goal->current_amount -= $contribution->amount;
|
||||||
|
if ($goal->status === 'completed') {
|
||||||
|
$goal->status = 'active';
|
||||||
|
$goal->completed_at = null;
|
||||||
|
}
|
||||||
|
$goal->save();
|
||||||
|
|
||||||
|
$contribution->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Contribución eliminada',
|
||||||
|
'data' => $goal->fresh('contributions'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
498
backend/app/Http/Controllers/Api/FinancialHealthController.php
Normal file
498
backend/app/Http/Controllers/Api/FinancialHealthController.php
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\LiabilityAccount;
|
||||||
|
use App\Models\Budget;
|
||||||
|
use App\Models\FinancialGoal;
|
||||||
|
use App\Models\RecurringTemplate;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class FinancialHealthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Obtener puntuación de salud financiera completa
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
|
||||||
|
// Calcular cada métrica
|
||||||
|
$savingsCapacity = $this->calculateSavingsCapacity($userId);
|
||||||
|
$debtControl = $this->calculateDebtControl($userId);
|
||||||
|
$budgetManagement = $this->calculateBudgetManagement($userId);
|
||||||
|
$investments = $this->calculateInvestments($userId);
|
||||||
|
$emergencyFund = $this->calculateEmergencyFund($userId);
|
||||||
|
$futurePlanning = $this->calculateFuturePlanning($userId);
|
||||||
|
|
||||||
|
// Puntuación general ponderada
|
||||||
|
$weights = [
|
||||||
|
'savings' => 0.25,
|
||||||
|
'debt' => 0.20,
|
||||||
|
'budget' => 0.15,
|
||||||
|
'investments' => 0.15,
|
||||||
|
'emergency' => 0.15,
|
||||||
|
'planning' => 0.10,
|
||||||
|
];
|
||||||
|
|
||||||
|
$overallScore = round(
|
||||||
|
($savingsCapacity['score'] * $weights['savings']) +
|
||||||
|
($debtControl['score'] * $weights['debt']) +
|
||||||
|
($budgetManagement['score'] * $weights['budget']) +
|
||||||
|
($investments['score'] * $weights['investments']) +
|
||||||
|
($emergencyFund['score'] * $weights['emergency']) +
|
||||||
|
($futurePlanning['score'] * $weights['planning'])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determinar nivel de salud
|
||||||
|
$healthLevel = $this->getHealthLevel($overallScore);
|
||||||
|
|
||||||
|
// Generar insights y recomendaciones
|
||||||
|
$insights = $this->generateInsights($userId, [
|
||||||
|
'savings' => $savingsCapacity,
|
||||||
|
'debt' => $debtControl,
|
||||||
|
'budget' => $budgetManagement,
|
||||||
|
'investments' => $investments,
|
||||||
|
'emergency' => $emergencyFund,
|
||||||
|
'planning' => $futurePlanning,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'overall_score' => $overallScore,
|
||||||
|
'health_level' => $healthLevel,
|
||||||
|
'last_updated' => now()->format('Y-m-d'),
|
||||||
|
'metrics' => [
|
||||||
|
'savings_capacity' => $savingsCapacity,
|
||||||
|
'debt_control' => $debtControl,
|
||||||
|
'budget_management' => $budgetManagement,
|
||||||
|
'investments' => $investments,
|
||||||
|
'emergency_fund' => $emergencyFund,
|
||||||
|
'future_planning' => $futurePlanning,
|
||||||
|
],
|
||||||
|
'insights' => $insights,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular capacidad de ahorro
|
||||||
|
*/
|
||||||
|
private function calculateSavingsCapacity($userId)
|
||||||
|
{
|
||||||
|
// Últimos 3 meses
|
||||||
|
$data = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$income = $data->income ?? 0;
|
||||||
|
$expense = $data->expense ?? 0;
|
||||||
|
$savings = $income - $expense;
|
||||||
|
$savingsRate = $income > 0 ? ($savings / $income) * 100 : 0;
|
||||||
|
|
||||||
|
// Puntuación basada en tasa de ahorro
|
||||||
|
// 0-10% = 40, 10-20% = 60, 20-30% = 80, 30%+ = 100
|
||||||
|
$score = match(true) {
|
||||||
|
$savingsRate >= 30 => 100,
|
||||||
|
$savingsRate >= 20 => 80 + (($savingsRate - 20) * 2),
|
||||||
|
$savingsRate >= 10 => 60 + (($savingsRate - 10) * 2),
|
||||||
|
$savingsRate >= 0 => 40 + ($savingsRate * 2),
|
||||||
|
default => max(0, 40 + $savingsRate),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round(min(100, max(0, $score))),
|
||||||
|
'savings_rate' => round($savingsRate, 1),
|
||||||
|
'monthly_savings' => round($savings / 3, 2),
|
||||||
|
'status' => $savingsRate >= 20 ? 'excellent' : ($savingsRate >= 10 ? 'good' : 'needs_improvement'),
|
||||||
|
'message' => $savingsRate >= 20
|
||||||
|
? "¡Excelente! Ahorras el {$savingsRate}% de tus ingresos"
|
||||||
|
: ($savingsRate >= 10
|
||||||
|
? "Buen trabajo. Ahorras el {$savingsRate}%"
|
||||||
|
: "Intenta aumentar tu tasa de ahorro"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular control de deudas
|
||||||
|
*/
|
||||||
|
private function calculateDebtControl($userId)
|
||||||
|
{
|
||||||
|
// Obtener deudas activas
|
||||||
|
$liabilities = LiabilityAccount::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalDebt = $liabilities->sum('current_balance');
|
||||||
|
$totalCreditLimit = $liabilities->sum('credit_limit');
|
||||||
|
$monthlyPayments = $liabilities->sum('monthly_payment');
|
||||||
|
|
||||||
|
// Ingresos mensuales promedio
|
||||||
|
$monthlyIncome = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_type', 'credit')
|
||||||
|
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
|
||||||
|
->sum('amount') / 3;
|
||||||
|
|
||||||
|
// Ratio deuda/ingresos
|
||||||
|
$debtToIncomeRatio = $monthlyIncome > 0 ? ($monthlyPayments / $monthlyIncome) * 100 : 0;
|
||||||
|
|
||||||
|
// Utilización de crédito
|
||||||
|
$creditUtilization = $totalCreditLimit > 0 ? ($totalDebt / $totalCreditLimit) * 100 : 0;
|
||||||
|
|
||||||
|
// Puntuación
|
||||||
|
// DTI < 20% = excelente, 20-35% = bueno, 35-50% = moderado, >50% = alto riesgo
|
||||||
|
$score = match(true) {
|
||||||
|
$debtToIncomeRatio <= 20 => 100 - ($debtToIncomeRatio * 0.5),
|
||||||
|
$debtToIncomeRatio <= 35 => 80 - (($debtToIncomeRatio - 20) * 1.3),
|
||||||
|
$debtToIncomeRatio <= 50 => 60 - (($debtToIncomeRatio - 35) * 2),
|
||||||
|
default => max(0, 30 - (($debtToIncomeRatio - 50) * 0.6)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round(min(100, max(0, $score))),
|
||||||
|
'total_debt' => round($totalDebt, 2),
|
||||||
|
'monthly_payments' => round($monthlyPayments, 2),
|
||||||
|
'debt_to_income_ratio' => round($debtToIncomeRatio, 1),
|
||||||
|
'credit_utilization' => round($creditUtilization, 1),
|
||||||
|
'active_debts' => $liabilities->count(),
|
||||||
|
'status' => $debtToIncomeRatio <= 20 ? 'excellent' : ($debtToIncomeRatio <= 35 ? 'acceptable' : 'needs_attention'),
|
||||||
|
'message' => $totalDebt == 0
|
||||||
|
? "¡Sin deudas! Excelente situación"
|
||||||
|
: ($debtToIncomeRatio <= 35
|
||||||
|
? "Nivel aceptable. Crédito disponible: " . number_format($totalCreditLimit - $totalDebt, 2) . "€"
|
||||||
|
: "Considera reducir tus deudas"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular gestión de presupuesto
|
||||||
|
*/
|
||||||
|
private function calculateBudgetManagement($userId)
|
||||||
|
{
|
||||||
|
$currentMonth = now()->month;
|
||||||
|
$currentYear = now()->year;
|
||||||
|
|
||||||
|
$budgets = Budget::where('user_id', $userId)
|
||||||
|
->where('year', $currentYear)
|
||||||
|
->where('month', $currentMonth)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($budgets->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'score' => 50,
|
||||||
|
'has_budgets' => false,
|
||||||
|
'status' => 'not_configured',
|
||||||
|
'message' => 'No tienes presupuestos configurados',
|
||||||
|
'exceeded_count' => 0,
|
||||||
|
'total_budgets' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$exceededCount = $budgets->filter(fn($b) => $b->is_exceeded)->count();
|
||||||
|
$totalBudgets = $budgets->count();
|
||||||
|
$complianceRate = (($totalBudgets - $exceededCount) / $totalBudgets) * 100;
|
||||||
|
|
||||||
|
// También verificar el uso promedio
|
||||||
|
$avgUsage = $budgets->avg('usage_percentage');
|
||||||
|
|
||||||
|
// Puntuación
|
||||||
|
$score = match(true) {
|
||||||
|
$exceededCount == 0 && $avgUsage <= 90 => 100,
|
||||||
|
$exceededCount == 0 => 85,
|
||||||
|
$exceededCount <= 1 => 70,
|
||||||
|
$exceededCount <= 2 => 55,
|
||||||
|
default => max(20, 55 - ($exceededCount * 10)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encontrar categoría más excedida
|
||||||
|
$mostExceeded = $budgets->filter(fn($b) => $b->is_exceeded)
|
||||||
|
->sortByDesc('usage_percentage')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round($score),
|
||||||
|
'has_budgets' => true,
|
||||||
|
'total_budgets' => $totalBudgets,
|
||||||
|
'exceeded_count' => $exceededCount,
|
||||||
|
'compliance_rate' => round($complianceRate, 1),
|
||||||
|
'avg_usage' => round($avgUsage, 1),
|
||||||
|
'most_exceeded' => $mostExceeded ? [
|
||||||
|
'category' => $mostExceeded->category?->name ?? $mostExceeded->name,
|
||||||
|
'usage' => $mostExceeded->usage_percentage,
|
||||||
|
] : null,
|
||||||
|
'status' => $exceededCount == 0 ? 'on_track' : ($exceededCount <= 2 ? 'needs_attention' : 'exceeded'),
|
||||||
|
'message' => $exceededCount == 0
|
||||||
|
? "Todos los presupuestos bajo control"
|
||||||
|
: "¡Atención! Excediste presupuesto en " . ($mostExceeded->category?->name ?? 'una categoría'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular inversiones
|
||||||
|
*/
|
||||||
|
private function calculateInvestments($userId)
|
||||||
|
{
|
||||||
|
// Cuentas de tipo inversión
|
||||||
|
$investmentAccounts = Account::where('user_id', $userId)
|
||||||
|
->where('type', 'investment')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$totalInvestments = $investmentAccounts->sum('current_balance');
|
||||||
|
|
||||||
|
// Total de activos
|
||||||
|
$totalAssets = Account::where('user_id', $userId)
|
||||||
|
->where('include_in_total', true)
|
||||||
|
->sum('current_balance');
|
||||||
|
|
||||||
|
$investmentRatio = $totalAssets > 0 ? ($totalInvestments / $totalAssets) * 100 : 0;
|
||||||
|
|
||||||
|
// Puntuación basada en ratio de inversión
|
||||||
|
$score = match(true) {
|
||||||
|
$investmentRatio >= 30 => 100,
|
||||||
|
$investmentRatio >= 20 => 80 + (($investmentRatio - 20) * 2),
|
||||||
|
$investmentRatio >= 10 => 60 + (($investmentRatio - 10) * 2),
|
||||||
|
$investmentRatio >= 5 => 40 + (($investmentRatio - 5) * 4),
|
||||||
|
default => max(20, $investmentRatio * 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round(min(100, $score)),
|
||||||
|
'total_investments' => round($totalInvestments, 2),
|
||||||
|
'investment_ratio' => round($investmentRatio, 1),
|
||||||
|
'accounts_count' => $investmentAccounts->count(),
|
||||||
|
'status' => $investmentRatio >= 20 ? 'good' : ($investmentRatio >= 10 ? 'moderate' : 'low'),
|
||||||
|
'message' => $investmentRatio >= 20
|
||||||
|
? "Buena diversificación. Sigue así"
|
||||||
|
: "Considera aumentar tu inversión",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular fondo de emergencia
|
||||||
|
*/
|
||||||
|
private function calculateEmergencyFund($userId)
|
||||||
|
{
|
||||||
|
// Cuentas líquidas (checking, savings, cash)
|
||||||
|
$liquidAccounts = Account::where('user_id', $userId)
|
||||||
|
->whereIn('type', ['checking', 'savings', 'cash'])
|
||||||
|
->where('include_in_total', true)
|
||||||
|
->sum('current_balance');
|
||||||
|
|
||||||
|
// Gastos mensuales promedio
|
||||||
|
$monthlyExpenses = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
|
||||||
|
->sum(fn($t) => abs($t->amount)) / 3;
|
||||||
|
|
||||||
|
// Usar consulta directa para mejor rendimiento
|
||||||
|
$monthlyExpenses = abs(Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->where('transaction_date', '>=', now()->subMonths(3)->startOfMonth())
|
||||||
|
->sum('amount')) / 3;
|
||||||
|
|
||||||
|
// Meses cubiertos
|
||||||
|
$monthsCovered = $monthlyExpenses > 0 ? $liquidAccounts / $monthlyExpenses : 0;
|
||||||
|
|
||||||
|
// Puntuación (ideal: 6 meses)
|
||||||
|
$score = match(true) {
|
||||||
|
$monthsCovered >= 6 => 100,
|
||||||
|
$monthsCovered >= 3 => 60 + (($monthsCovered - 3) * 13.33),
|
||||||
|
$monthsCovered >= 1 => 30 + (($monthsCovered - 1) * 15),
|
||||||
|
default => max(0, $monthsCovered * 30),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round(min(100, $score)),
|
||||||
|
'liquid_assets' => round($liquidAccounts, 2),
|
||||||
|
'monthly_expenses' => round($monthlyExpenses, 2),
|
||||||
|
'months_covered' => round($monthsCovered, 1),
|
||||||
|
'recommended_fund' => round($monthlyExpenses * 6, 2),
|
||||||
|
'status' => $monthsCovered >= 6 ? 'excellent' : ($monthsCovered >= 3 ? 'moderate' : 'insufficient'),
|
||||||
|
'message' => $monthsCovered >= 6
|
||||||
|
? "Excelente fondo de emergencia"
|
||||||
|
: "Tienes " . number_format($liquidAccounts, 2) . "€ (" . round($monthsCovered, 1) . " meses de gastos)",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular planificación futura
|
||||||
|
*/
|
||||||
|
private function calculateFuturePlanning($userId)
|
||||||
|
{
|
||||||
|
// Metas financieras activas
|
||||||
|
$goals = FinancialGoal::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Transacciones recurrentes configuradas
|
||||||
|
$recurringCount = RecurringTemplate::where('user_id', $userId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Presupuestos configurados
|
||||||
|
$hasBudgets = Budget::where('user_id', $userId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
$activeGoals = $goals->count();
|
||||||
|
$goalsOnTrack = $goals->filter(fn($g) => $g->is_on_track)->count();
|
||||||
|
$avgProgress = $goals->avg('progress_percentage') ?? 0;
|
||||||
|
|
||||||
|
// Puntuación
|
||||||
|
$baseScore = 40;
|
||||||
|
if ($activeGoals > 0) $baseScore += 20;
|
||||||
|
if ($activeGoals >= 3) $baseScore += 10;
|
||||||
|
if ($goalsOnTrack > 0) $baseScore += 15;
|
||||||
|
if ($hasBudgets) $baseScore += 10;
|
||||||
|
if ($recurringCount >= 3) $baseScore += 5;
|
||||||
|
|
||||||
|
// Bonus por progreso
|
||||||
|
$baseScore += min(10, $avgProgress / 10);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => round(min(100, $baseScore)),
|
||||||
|
'active_goals' => $activeGoals,
|
||||||
|
'goals_on_track' => $goalsOnTrack,
|
||||||
|
'avg_progress' => round($avgProgress, 1),
|
||||||
|
'has_budgets' => $hasBudgets,
|
||||||
|
'recurring_configured' => $recurringCount,
|
||||||
|
'status' => $activeGoals >= 3 ? 'excellent' : ($activeGoals >= 1 ? 'good' : 'needs_setup'),
|
||||||
|
'message' => $activeGoals > 0
|
||||||
|
? "¡Muy bien! Tienes $activeGoals metas activas en progreso"
|
||||||
|
: "Crea metas financieras para mejorar tu planificación",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determinar nivel de salud
|
||||||
|
*/
|
||||||
|
private function getHealthLevel($score)
|
||||||
|
{
|
||||||
|
return match(true) {
|
||||||
|
$score >= 85 => ['level' => 'excellent', 'label' => 'Excelente Salud', 'color' => '#22c55e'],
|
||||||
|
$score >= 70 => ['level' => 'good', 'label' => 'Buena Salud', 'color' => '#84cc16'],
|
||||||
|
$score >= 55 => ['level' => 'moderate', 'label' => 'Salud Moderada', 'color' => '#f59e0b'],
|
||||||
|
$score >= 40 => ['level' => 'needs_work', 'label' => 'Necesita Mejorar', 'color' => '#f97316'],
|
||||||
|
default => ['level' => 'critical', 'label' => 'Atención Necesaria', 'color' => '#ef4444'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar insights y recomendaciones
|
||||||
|
*/
|
||||||
|
private function generateInsights($userId, $metrics)
|
||||||
|
{
|
||||||
|
$insights = [];
|
||||||
|
|
||||||
|
// Prioridad Alta - Problemas críticos
|
||||||
|
if ($metrics['debt']['score'] < 50) {
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'high_priority',
|
||||||
|
'icon' => 'bi-exclamation-triangle-fill',
|
||||||
|
'color' => '#ef4444',
|
||||||
|
'title' => 'Prioridad Alta',
|
||||||
|
'message' => "Reduce gastos en " . ($metrics['budget']['most_exceeded']['category'] ?? 'categorías con exceso') . ". Estás excediendo el presupuesto en " . ($metrics['budget']['most_exceeded']['usage'] ?? 0) . "%.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioridad Media - Mejoras recomendadas
|
||||||
|
if ($metrics['emergency']['months_covered'] < 6) {
|
||||||
|
$needed = $metrics['emergency']['recommended_fund'] - $metrics['emergency']['liquid_assets'];
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'medium_priority',
|
||||||
|
'icon' => 'bi-shield-exclamation',
|
||||||
|
'color' => '#f59e0b',
|
||||||
|
'title' => 'Prioridad Media',
|
||||||
|
'message' => "Incrementa tu fondo de emergencia a 6 meses de gastos (" . number_format($metrics['emergency']['recommended_fund'], 0) . "€).",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logros
|
||||||
|
if ($metrics['savings']['savings_rate'] >= 20) {
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'achievement',
|
||||||
|
'icon' => 'bi-trophy-fill',
|
||||||
|
'color' => '#22c55e',
|
||||||
|
'title' => 'Logro Destacado',
|
||||||
|
'message' => "¡Excelente ahorro mensual! Mantienes una tasa del " . $metrics['savings']['savings_rate'] . "% por encima de la meta.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oportunidades
|
||||||
|
if ($metrics['investments']['investment_ratio'] < 20 && $metrics['savings']['savings_rate'] > 15) {
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'opportunity',
|
||||||
|
'icon' => 'bi-lightbulb-fill',
|
||||||
|
'color' => '#3b82f6',
|
||||||
|
'title' => 'Oportunidad',
|
||||||
|
'message' => "Considera aumentar tu inversión con el excedente de tus ahorros.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metas próximas
|
||||||
|
$upcomingGoals = FinancialGoal::where('user_id', $userId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->whereNotNull('target_date')
|
||||||
|
->where('target_date', '<=', now()->addMonths(6))
|
||||||
|
->orderBy('target_date')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($upcomingGoals) {
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'upcoming_goal',
|
||||||
|
'icon' => 'bi-calendar-check-fill',
|
||||||
|
'color' => '#8b5cf6',
|
||||||
|
'title' => 'Meta Próxima',
|
||||||
|
'message' => "Faltan " . now()->diffInMonths($upcomingGoals->target_date) . " meses para tu meta de " . $upcomingGoals->name . ". ¡Sigue así!",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sugerencia general
|
||||||
|
if ($metrics['planning']['active_goals'] < 3) {
|
||||||
|
$insights[] = [
|
||||||
|
'type' => 'suggestion',
|
||||||
|
'icon' => 'bi-gear-fill',
|
||||||
|
'color' => '#06b6d4',
|
||||||
|
'title' => 'Sugerencia',
|
||||||
|
'message' => "Activa ahorro automático del 10% cada vez que recibes tu nómina.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $insights;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historial de puntuación
|
||||||
|
*/
|
||||||
|
public function history(Request $request)
|
||||||
|
{
|
||||||
|
// Por ahora retornamos datos simulados
|
||||||
|
// En producción, guardaríamos snapshots diarios/semanales
|
||||||
|
$months = $request->get('months', 6);
|
||||||
|
|
||||||
|
$history = [];
|
||||||
|
$baseScore = 72;
|
||||||
|
|
||||||
|
for ($i = $months - 1; $i >= 0; $i--) {
|
||||||
|
$date = now()->subMonths($i);
|
||||||
|
$variation = rand(-5, 8);
|
||||||
|
$baseScore = max(30, min(95, $baseScore + $variation));
|
||||||
|
|
||||||
|
$history[] = [
|
||||||
|
'month' => $date->format('Y-m'),
|
||||||
|
'month_label' => $date->format('M Y'),
|
||||||
|
'score' => $baseScore,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($history);
|
||||||
|
}
|
||||||
|
}
|
||||||
453
backend/app/Http/Controllers/Api/ReportController.php
Normal file
453
backend/app/Http/Controllers/Api/ReportController.php
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use App\Models\Category;
|
||||||
|
use App\Models\Account;
|
||||||
|
use App\Models\LiabilityAccount;
|
||||||
|
use App\Models\RecurringTemplate;
|
||||||
|
use App\Models\Budget;
|
||||||
|
use App\Models\FinancialGoal;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class ReportController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resumen general de reportes
|
||||||
|
*/
|
||||||
|
public function summary(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$year = $request->get('year', now()->year);
|
||||||
|
|
||||||
|
// Ingresos y gastos del año
|
||||||
|
$yearData = Transaction::where('user_id', $userId)
|
||||||
|
->whereYear('transaction_date', $year)
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Comparar con año anterior
|
||||||
|
$lastYearData = Transaction::where('user_id', $userId)
|
||||||
|
->whereYear('transaction_date', $year - 1)
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'year' => $year,
|
||||||
|
'current' => [
|
||||||
|
'income' => $yearData->income ?? 0,
|
||||||
|
'expense' => $yearData->expense ?? 0,
|
||||||
|
'balance' => ($yearData->income ?? 0) - ($yearData->expense ?? 0),
|
||||||
|
],
|
||||||
|
'previous' => [
|
||||||
|
'income' => $lastYearData->income ?? 0,
|
||||||
|
'expense' => $lastYearData->expense ?? 0,
|
||||||
|
'balance' => ($lastYearData->income ?? 0) - ($lastYearData->expense ?? 0),
|
||||||
|
],
|
||||||
|
'variation' => [
|
||||||
|
'income' => $lastYearData->income > 0
|
||||||
|
? round((($yearData->income - $lastYearData->income) / $lastYearData->income) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
'expense' => $lastYearData->expense > 0
|
||||||
|
? round((($yearData->expense - $lastYearData->expense) / $lastYearData->expense) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte por categorías
|
||||||
|
*/
|
||||||
|
public function byCategory(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$startDate = $request->get('start_date', now()->startOfYear()->format('Y-m-d'));
|
||||||
|
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||||
|
$type = $request->get('type', 'debit'); // debit o credit
|
||||||
|
|
||||||
|
$data = Transaction::where('user_id', $userId)
|
||||||
|
->whereBetween('transaction_date', [$startDate, $endDate])
|
||||||
|
->where('transaction_type', $type)
|
||||||
|
->whereNotNull('category_id')
|
||||||
|
->join('categories', 'transactions.category_id', '=', 'categories.id')
|
||||||
|
->selectRaw("
|
||||||
|
COALESCE(categories.parent_id, categories.id) as category_group_id,
|
||||||
|
SUM(ABS(transactions.amount)) as total
|
||||||
|
")
|
||||||
|
->groupBy('category_group_id')
|
||||||
|
->orderByDesc('total')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Obtener nombres de categorías
|
||||||
|
$categoryIds = $data->pluck('category_group_id')->unique();
|
||||||
|
$categories = Category::whereIn('id', $categoryIds)->get()->keyBy('id');
|
||||||
|
|
||||||
|
$result = $data->map(function($item) use ($categories) {
|
||||||
|
$category = $categories->get($item->category_group_id);
|
||||||
|
return [
|
||||||
|
'category_id' => $item->category_group_id,
|
||||||
|
'category_name' => $category ? $category->name : 'Sin categoría',
|
||||||
|
'icon' => $category ? $category->icon : 'bi-tag',
|
||||||
|
'color' => $category ? $category->color : '#6b7280',
|
||||||
|
'total' => round($item->total, 2),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$grandTotal = $result->sum('total');
|
||||||
|
|
||||||
|
// Añadir porcentajes
|
||||||
|
$result = $result->map(function($item) use ($grandTotal) {
|
||||||
|
$item['percentage'] = $grandTotal > 0 ? round(($item['total'] / $grandTotal) * 100, 1) : 0;
|
||||||
|
return $item;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $result->values(),
|
||||||
|
'total' => $grandTotal,
|
||||||
|
'period' => [
|
||||||
|
'start' => $startDate,
|
||||||
|
'end' => $endDate,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte de evolución mensual
|
||||||
|
*/
|
||||||
|
public function monthlyEvolution(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$months = $request->get('months', 12);
|
||||||
|
|
||||||
|
$data = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_date', '>=', now()->subMonths($months)->startOfMonth())
|
||||||
|
->selectRaw("
|
||||||
|
DATE_FORMAT(transaction_date, '%Y-%m') as month,
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->groupBy('month')
|
||||||
|
->orderBy('month')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Calcular balance acumulado y tasa de ahorro
|
||||||
|
$result = $data->map(function($item) {
|
||||||
|
$balance = $item->income - $item->expense;
|
||||||
|
$savingsRate = $item->income > 0 ? round(($balance / $item->income) * 100, 1) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'month' => $item->month,
|
||||||
|
'month_label' => Carbon::parse($item->month . '-01')->format('M Y'),
|
||||||
|
'income' => round($item->income, 2),
|
||||||
|
'expense' => round($item->expense, 2),
|
||||||
|
'balance' => round($balance, 2),
|
||||||
|
'savings_rate' => $savingsRate,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $result,
|
||||||
|
'averages' => [
|
||||||
|
'income' => round($result->avg('income'), 2),
|
||||||
|
'expense' => round($result->avg('expense'), 2),
|
||||||
|
'balance' => round($result->avg('balance'), 2),
|
||||||
|
'savings_rate' => round($result->avg('savings_rate'), 1),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte de gastos por día de la semana
|
||||||
|
*/
|
||||||
|
public function byDayOfWeek(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$months = $request->get('months', 6);
|
||||||
|
|
||||||
|
$data = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->where('transaction_date', '>=', now()->subMonths($months))
|
||||||
|
->selectRaw("
|
||||||
|
DAYOFWEEK(transaction_date) as day_num,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(ABS(amount)) as total,
|
||||||
|
AVG(ABS(amount)) as average
|
||||||
|
")
|
||||||
|
->groupBy('day_num')
|
||||||
|
->orderBy('day_num')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$days = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||||
|
|
||||||
|
$result = $data->map(function($item) use ($days) {
|
||||||
|
return [
|
||||||
|
'day' => $days[$item->day_num - 1],
|
||||||
|
'day_num' => $item->day_num,
|
||||||
|
'count' => $item->count,
|
||||||
|
'total' => round($item->total, 2),
|
||||||
|
'average' => round($item->average, 2),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top gastos
|
||||||
|
*/
|
||||||
|
public function topExpenses(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$startDate = $request->get('start_date', now()->startOfMonth()->format('Y-m-d'));
|
||||||
|
$endDate = $request->get('end_date', now()->format('Y-m-d'));
|
||||||
|
$limit = $request->get('limit', 20);
|
||||||
|
|
||||||
|
$transactions = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->whereBetween('transaction_date', [$startDate, $endDate])
|
||||||
|
->with(['category', 'account'])
|
||||||
|
->orderByRaw('ABS(amount) DESC')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => $transactions->map(function($t) {
|
||||||
|
return [
|
||||||
|
'id' => $t->id,
|
||||||
|
'description' => $t->description,
|
||||||
|
'amount' => abs($t->amount),
|
||||||
|
'date' => $t->transaction_date->format('Y-m-d'),
|
||||||
|
'category' => $t->category ? $t->category->name : null,
|
||||||
|
'account' => $t->account ? $t->account->name : null,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
'total' => $transactions->sum(fn($t) => abs($t->amount)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparativa de períodos
|
||||||
|
*/
|
||||||
|
public function comparePeriods(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$period1Start = $request->get('period1_start');
|
||||||
|
$period1End = $request->get('period1_end');
|
||||||
|
$period2Start = $request->get('period2_start');
|
||||||
|
$period2End = $request->get('period2_end');
|
||||||
|
|
||||||
|
// Si no se especifican, comparar mes actual vs anterior
|
||||||
|
if (!$period1Start) {
|
||||||
|
$period1Start = now()->startOfMonth()->format('Y-m-d');
|
||||||
|
$period1End = now()->endOfMonth()->format('Y-m-d');
|
||||||
|
$period2Start = now()->subMonth()->startOfMonth()->format('Y-m-d');
|
||||||
|
$period2End = now()->subMonth()->endOfMonth()->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
$getPeriodData = function($start, $end) use ($userId) {
|
||||||
|
return Transaction::where('user_id', $userId)
|
||||||
|
->whereBetween('transaction_date', [$start, $end])
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense,
|
||||||
|
COUNT(*) as transactions
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
};
|
||||||
|
|
||||||
|
$period1 = $getPeriodData($period1Start, $period1End);
|
||||||
|
$period2 = $getPeriodData($period2Start, $period2End);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'period1' => [
|
||||||
|
'label' => Carbon::parse($period1Start)->format('M Y'),
|
||||||
|
'start' => $period1Start,
|
||||||
|
'end' => $period1End,
|
||||||
|
'income' => $period1->income ?? 0,
|
||||||
|
'expense' => $period1->expense ?? 0,
|
||||||
|
'balance' => ($period1->income ?? 0) - ($period1->expense ?? 0),
|
||||||
|
'transactions' => $period1->transactions ?? 0,
|
||||||
|
],
|
||||||
|
'period2' => [
|
||||||
|
'label' => Carbon::parse($period2Start)->format('M Y'),
|
||||||
|
'start' => $period2Start,
|
||||||
|
'end' => $period2End,
|
||||||
|
'income' => $period2->income ?? 0,
|
||||||
|
'expense' => $period2->expense ?? 0,
|
||||||
|
'balance' => ($period2->income ?? 0) - ($period2->expense ?? 0),
|
||||||
|
'transactions' => $period2->transactions ?? 0,
|
||||||
|
],
|
||||||
|
'variation' => [
|
||||||
|
'income' => ($period2->income ?? 0) > 0
|
||||||
|
? round(((($period1->income ?? 0) - ($period2->income ?? 0)) / ($period2->income ?? 1)) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
'expense' => ($period2->expense ?? 0) > 0
|
||||||
|
? round(((($period1->expense ?? 0) - ($period2->expense ?? 0)) / ($period2->expense ?? 1)) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte de cuentas
|
||||||
|
*/
|
||||||
|
public function accountsReport(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
|
||||||
|
$accounts = Account::where('user_id', $userId)
|
||||||
|
->withCount('transactions')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$result = $accounts->map(function($account) {
|
||||||
|
// Últimas transacciones
|
||||||
|
$recentActivity = Transaction::where('account_id', $account->id)
|
||||||
|
->orderBy('transaction_date', 'desc')
|
||||||
|
->limit(5)
|
||||||
|
->get(['id', 'description', 'amount', 'transaction_type', 'transaction_date']);
|
||||||
|
|
||||||
|
// Movimientos del mes
|
||||||
|
$monthStats = Transaction::where('account_id', $account->id)
|
||||||
|
->whereMonth('transaction_date', now()->month)
|
||||||
|
->whereYear('transaction_date', now()->year)
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $account->id,
|
||||||
|
'name' => $account->name,
|
||||||
|
'type' => $account->type,
|
||||||
|
'currency' => $account->currency,
|
||||||
|
'balance' => $account->current_balance,
|
||||||
|
'transactions_count' => $account->transactions_count,
|
||||||
|
'month_income' => $monthStats->income ?? 0,
|
||||||
|
'month_expense' => $monthStats->expense ?? 0,
|
||||||
|
'recent_activity' => $recentActivity,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'accounts' => $result,
|
||||||
|
'summary' => [
|
||||||
|
'total_accounts' => $accounts->count(),
|
||||||
|
'total_balance' => $accounts->where('include_in_total', true)->sum('current_balance'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proyección de gastos
|
||||||
|
*/
|
||||||
|
public function projection(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$months = $request->get('months', 3);
|
||||||
|
|
||||||
|
// Obtener promedio de los últimos meses
|
||||||
|
$historical = Transaction::where('user_id', $userId)
|
||||||
|
->where('transaction_date', '>=', now()->subMonths($months)->startOfMonth())
|
||||||
|
->where('transaction_date', '<', now()->startOfMonth())
|
||||||
|
->selectRaw("
|
||||||
|
AVG(CASE WHEN transaction_type = 'credit' THEN amount ELSE NULL END) as avg_income,
|
||||||
|
AVG(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE NULL END) as avg_expense,
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) / ? as monthly_income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) / ? as monthly_expense
|
||||||
|
", [$months, $months])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Gastos del mes actual
|
||||||
|
$currentMonth = Transaction::where('user_id', $userId)
|
||||||
|
->whereMonth('transaction_date', now()->month)
|
||||||
|
->whereYear('transaction_date', now()->year)
|
||||||
|
->selectRaw("
|
||||||
|
SUM(CASE WHEN transaction_type = 'credit' THEN amount ELSE 0 END) as income,
|
||||||
|
SUM(CASE WHEN transaction_type = 'debit' THEN ABS(amount) ELSE 0 END) as expense
|
||||||
|
")
|
||||||
|
->first();
|
||||||
|
|
||||||
|
// Días transcurridos y restantes
|
||||||
|
$daysElapsed = now()->day;
|
||||||
|
$daysInMonth = now()->daysInMonth;
|
||||||
|
$daysRemaining = $daysInMonth - $daysElapsed;
|
||||||
|
|
||||||
|
// Proyección del mes
|
||||||
|
$projectedExpense = ($currentMonth->expense / $daysElapsed) * $daysInMonth;
|
||||||
|
$projectedIncome = ($currentMonth->income / $daysElapsed) * $daysInMonth;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'historical_average' => [
|
||||||
|
'income' => round($historical->monthly_income ?? 0, 2),
|
||||||
|
'expense' => round($historical->monthly_expense ?? 0, 2),
|
||||||
|
],
|
||||||
|
'current_month' => [
|
||||||
|
'income' => round($currentMonth->income ?? 0, 2),
|
||||||
|
'expense' => round($currentMonth->expense ?? 0, 2),
|
||||||
|
'days_elapsed' => $daysElapsed,
|
||||||
|
'days_remaining' => $daysRemaining,
|
||||||
|
],
|
||||||
|
'projection' => [
|
||||||
|
'income' => round($projectedIncome, 2),
|
||||||
|
'expense' => round($projectedExpense, 2),
|
||||||
|
'balance' => round($projectedIncome - $projectedExpense, 2),
|
||||||
|
],
|
||||||
|
'vs_average' => [
|
||||||
|
'income' => ($historical->monthly_income ?? 0) > 0
|
||||||
|
? round((($projectedIncome - ($historical->monthly_income ?? 0)) / ($historical->monthly_income ?? 1)) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
'expense' => ($historical->monthly_expense ?? 0) > 0
|
||||||
|
? round((($projectedExpense - ($historical->monthly_expense ?? 0)) / ($historical->monthly_expense ?? 1)) * 100, 1)
|
||||||
|
: 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reporte de recurrencias
|
||||||
|
*/
|
||||||
|
public function recurringReport(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
|
||||||
|
$templates = RecurringTemplate::where('user_id', $userId)
|
||||||
|
->where('is_active', true)
|
||||||
|
->with(['category', 'account'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$monthlyIncome = $templates->where('transaction_type', 'credit')->sum('amount');
|
||||||
|
$monthlyExpense = $templates->where('transaction_type', 'debit')->sum(fn($t) => abs($t->amount));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'templates' => $templates->map(function($t) {
|
||||||
|
return [
|
||||||
|
'id' => $t->id,
|
||||||
|
'description' => $t->description,
|
||||||
|
'amount' => abs($t->amount),
|
||||||
|
'type' => $t->transaction_type,
|
||||||
|
'frequency' => $t->frequency,
|
||||||
|
'category' => $t->category ? $t->category->name : null,
|
||||||
|
'next_date' => $t->next_occurrence_date,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
'summary' => [
|
||||||
|
'total_recurring' => $templates->count(),
|
||||||
|
'monthly_income' => $monthlyIncome,
|
||||||
|
'monthly_expense' => $monthlyExpense,
|
||||||
|
'net_recurring' => $monthlyIncome - $monthlyExpense,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
164
backend/app/Models/Budget.php
Normal file
164
backend/app/Models/Budget.php
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class Budget extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'category_id',
|
||||||
|
'name',
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'year',
|
||||||
|
'month',
|
||||||
|
'period_type',
|
||||||
|
'is_active',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'spent_amount',
|
||||||
|
'remaining_amount',
|
||||||
|
'usage_percentage',
|
||||||
|
'is_exceeded',
|
||||||
|
'period_label',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Relaciones
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Accessors
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function getSpentAmountAttribute()
|
||||||
|
{
|
||||||
|
// Calcular el gasto real de las transacciones
|
||||||
|
$query = Transaction::where('user_id', $this->user_id)
|
||||||
|
->where('transaction_type', 'debit')
|
||||||
|
->whereYear('transaction_date', $this->year);
|
||||||
|
|
||||||
|
if ($this->period_type === 'monthly' && $this->month) {
|
||||||
|
$query->whereMonth('transaction_date', $this->month);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->category_id) {
|
||||||
|
// Incluir subcategorías
|
||||||
|
$categoryIds = [$this->category_id];
|
||||||
|
$subcategories = Category::where('parent_id', $this->category_id)->pluck('id')->toArray();
|
||||||
|
$categoryIds = array_merge($categoryIds, $subcategories);
|
||||||
|
|
||||||
|
$query->whereIn('category_id', $categoryIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return abs($query->sum('amount'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemainingAmountAttribute()
|
||||||
|
{
|
||||||
|
return $this->amount - $this->spent_amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsagePercentageAttribute()
|
||||||
|
{
|
||||||
|
if ($this->amount <= 0) return 0;
|
||||||
|
return round(($this->spent_amount / $this->amount) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsExceededAttribute()
|
||||||
|
{
|
||||||
|
return $this->spent_amount > $this->amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPeriodLabelAttribute()
|
||||||
|
{
|
||||||
|
if ($this->period_type === 'yearly') {
|
||||||
|
return $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
$months = [
|
||||||
|
1 => 'Enero', 2 => 'Febrero', 3 => 'Marzo', 4 => 'Abril',
|
||||||
|
5 => 'Mayo', 6 => 'Junio', 7 => 'Julio', 8 => 'Agosto',
|
||||||
|
9 => 'Septiembre', 10 => 'Octubre', 11 => 'Noviembre', 12 => 'Diciembre'
|
||||||
|
];
|
||||||
|
|
||||||
|
return ($months[$this->month] ?? '') . ' ' . $this->year;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public static function copyToNextMonth($userId, $fromYear, $fromMonth)
|
||||||
|
{
|
||||||
|
$nextMonth = $fromMonth === 12 ? 1 : $fromMonth + 1;
|
||||||
|
$nextYear = $fromMonth === 12 ? $fromYear + 1 : $fromYear;
|
||||||
|
|
||||||
|
$budgets = self::where('user_id', $userId)
|
||||||
|
->where('year', $fromYear)
|
||||||
|
->where('month', $fromMonth)
|
||||||
|
->where('is_active', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($budgets as $budget) {
|
||||||
|
self::firstOrCreate([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'category_id' => $budget->category_id,
|
||||||
|
'year' => $nextYear,
|
||||||
|
'month' => $nextMonth,
|
||||||
|
], [
|
||||||
|
'name' => $budget->name,
|
||||||
|
'amount' => $budget->amount,
|
||||||
|
'currency' => $budget->currency,
|
||||||
|
'period_type' => 'monthly',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scopes
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForPeriod($query, $year, $month = null)
|
||||||
|
{
|
||||||
|
$query->where('year', $year);
|
||||||
|
if ($month) {
|
||||||
|
$query->where('month', $month);
|
||||||
|
}
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForUser($query, $userId)
|
||||||
|
{
|
||||||
|
return $query->where('user_id', $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
backend/app/Models/FinancialGoal.php
Normal file
169
backend/app/Models/FinancialGoal.php
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class FinancialGoal extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'icon',
|
||||||
|
'color',
|
||||||
|
'target_amount',
|
||||||
|
'current_amount',
|
||||||
|
'currency',
|
||||||
|
'target_date',
|
||||||
|
'start_date',
|
||||||
|
'monthly_contribution',
|
||||||
|
'status',
|
||||||
|
'completed_at',
|
||||||
|
'priority',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'target_amount' => 'decimal:2',
|
||||||
|
'current_amount' => 'decimal:2',
|
||||||
|
'monthly_contribution' => 'decimal:2',
|
||||||
|
'target_date' => 'date',
|
||||||
|
'start_date' => 'date',
|
||||||
|
'completed_at' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = [
|
||||||
|
'progress_percentage',
|
||||||
|
'remaining_amount',
|
||||||
|
'months_remaining',
|
||||||
|
'required_monthly_saving',
|
||||||
|
'is_on_track',
|
||||||
|
'status_label',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Relaciones
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contributions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(GoalContribution::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Accessors
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function getProgressPercentageAttribute()
|
||||||
|
{
|
||||||
|
if ($this->target_amount <= 0) return 0;
|
||||||
|
return min(100, round(($this->current_amount / $this->target_amount) * 100, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRemainingAmountAttribute()
|
||||||
|
{
|
||||||
|
return max(0, $this->target_amount - $this->current_amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMonthsRemainingAttribute()
|
||||||
|
{
|
||||||
|
if (!$this->target_date) return null;
|
||||||
|
|
||||||
|
$now = Carbon::now();
|
||||||
|
$target = Carbon::parse($this->target_date);
|
||||||
|
|
||||||
|
if ($target->isPast()) return 0;
|
||||||
|
|
||||||
|
return $now->diffInMonths($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequiredMonthlySavingAttribute()
|
||||||
|
{
|
||||||
|
if (!$this->months_remaining || $this->months_remaining <= 0) {
|
||||||
|
return $this->remaining_amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($this->remaining_amount / $this->months_remaining, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIsOnTrackAttribute()
|
||||||
|
{
|
||||||
|
if (!$this->monthly_contribution || !$this->required_monthly_saving) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->monthly_contribution >= $this->required_monthly_saving;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusLabelAttribute()
|
||||||
|
{
|
||||||
|
return match($this->status) {
|
||||||
|
'active' => 'En progreso',
|
||||||
|
'completed' => 'Completada',
|
||||||
|
'paused' => 'Pausada',
|
||||||
|
'cancelled' => 'Cancelada',
|
||||||
|
default => $this->status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Methods
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function addContribution($amount, $date = null, $transactionId = null, $notes = null)
|
||||||
|
{
|
||||||
|
$contribution = $this->contributions()->create([
|
||||||
|
'amount' => $amount,
|
||||||
|
'contribution_date' => $date ?? now(),
|
||||||
|
'transaction_id' => $transactionId,
|
||||||
|
'notes' => $notes,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->current_amount += $amount;
|
||||||
|
|
||||||
|
// Verificar si se completó la meta
|
||||||
|
if ($this->current_amount >= $this->target_amount) {
|
||||||
|
$this->status = 'completed';
|
||||||
|
$this->completed_at = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $contribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsCompleted()
|
||||||
|
{
|
||||||
|
$this->status = 'completed';
|
||||||
|
$this->completed_at = now();
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Scopes
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeCompleted($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', 'completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForUser($query, $userId)
|
||||||
|
{
|
||||||
|
return $query->where('user_id', $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/app/Models/GoalContribution.php
Normal file
38
backend/app/Models/GoalContribution.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class GoalContribution extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'financial_goal_id',
|
||||||
|
'transaction_id',
|
||||||
|
'amount',
|
||||||
|
'contribution_date',
|
||||||
|
'notes',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount' => 'decimal:2',
|
||||||
|
'contribution_date' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Relaciones
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
public function goal()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(FinancialGoal::class, 'financial_goal_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function transaction()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Transaction::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('financial_goals', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('icon')->default('bi-bullseye');
|
||||||
|
$table->string('color')->default('#f59e0b');
|
||||||
|
$table->decimal('target_amount', 15, 2);
|
||||||
|
$table->decimal('current_amount', 15, 2)->default(0);
|
||||||
|
$table->string('currency', 3)->default('EUR');
|
||||||
|
$table->date('target_date')->nullable();
|
||||||
|
$table->date('start_date');
|
||||||
|
$table->decimal('monthly_contribution', 15, 2)->nullable();
|
||||||
|
$table->enum('status', ['active', 'completed', 'paused', 'cancelled'])->default('active');
|
||||||
|
$table->date('completed_at')->nullable();
|
||||||
|
$table->enum('priority', ['high', 'medium', 'low'])->default('medium');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'status']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tabla para contribuciones a metas
|
||||||
|
Schema::create('goal_contributions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('financial_goal_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('transaction_id')->nullable()->constrained()->onDelete('set null');
|
||||||
|
$table->decimal('amount', 15, 2);
|
||||||
|
$table->date('contribution_date');
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('financial_goal_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('goal_contributions');
|
||||||
|
Schema::dropIfExists('financial_goals');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('budgets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained()->onDelete('cascade');
|
||||||
|
$table->string('name')->nullable(); // Para presupuestos personalizados sin categoría
|
||||||
|
$table->decimal('amount', 15, 2);
|
||||||
|
$table->string('currency', 3)->default('EUR');
|
||||||
|
$table->integer('year');
|
||||||
|
$table->integer('month'); // 1-12, null para presupuesto anual
|
||||||
|
$table->enum('period_type', ['monthly', 'yearly'])->default('monthly');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'category_id', 'year', 'month'], 'unique_budget_category_period');
|
||||||
|
$table->index(['user_id', 'year', 'month']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('budgets');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -17,6 +17,10 @@
|
|||||||
use App\Http\Controllers\Api\ProductSheetController;
|
use App\Http\Controllers\Api\ProductSheetController;
|
||||||
use App\Http\Controllers\Api\ServiceSheetController;
|
use App\Http\Controllers\Api\ServiceSheetController;
|
||||||
use App\Http\Controllers\Api\PromotionalCampaignController;
|
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
|
// Public routes with rate limiting
|
||||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||||
@ -232,5 +236,41 @@
|
|||||||
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
|
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
|
||||||
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
|
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
|
||||||
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
|
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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,10 @@ import TransferDetection from './pages/TransferDetection';
|
|||||||
import RefundDetection from './pages/RefundDetection';
|
import RefundDetection from './pages/RefundDetection';
|
||||||
import RecurringTransactions from './pages/RecurringTransactions';
|
import RecurringTransactions from './pages/RecurringTransactions';
|
||||||
import Business from './pages/Business';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -135,6 +139,46 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/financial-health"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<FinancialHealth />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/goals"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Goals />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/budgets"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Budgets />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/reports"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout>
|
||||||
|
<Reports />
|
||||||
|
</Layout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const Layout = ({ children }) => {
|
|||||||
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState({
|
const [expandedGroups, setExpandedGroups] = useState({
|
||||||
movements: true,
|
movements: true,
|
||||||
|
planning: true,
|
||||||
settings: false,
|
settings: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -64,6 +65,18 @@ const Layout = ({ children }) => {
|
|||||||
},
|
},
|
||||||
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
||||||
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
|
{ 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',
|
type: 'group',
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
|
|||||||
@ -90,7 +90,11 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"business": "Negocio",
|
"business": "Negocio",
|
||||||
"profile": "Perfil",
|
"profile": "Perfil",
|
||||||
"help": "Ayuda"
|
"help": "Ayuda",
|
||||||
|
"planning": "Planificación",
|
||||||
|
"financialHealth": "Salud Financiera",
|
||||||
|
"goals": "Metas",
|
||||||
|
"budgets": "Presupuestos"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Panel de Control",
|
"title": "Panel de Control",
|
||||||
@ -1477,5 +1481,178 @@
|
|||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"totalCmv": "CMV Total"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
594
frontend/src/pages/Budgets.jsx
Normal file
594
frontend/src/pages/Budgets.jsx
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { budgetService, categoryService } from '../services/api';
|
||||||
|
import useFormatters from '../hooks/useFormatters';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
|
||||||
|
const Budgets = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { currency } = useFormatters();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [budgets, setBudgets] = useState([]);
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [availableCategories, setAvailableCategories] = useState([]);
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [month, setMonth] = useState(new Date().getMonth() + 1);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editingBudget, setEditingBudget] = useState(null);
|
||||||
|
const [deleteBudget, setDeleteBudget] = useState(null);
|
||||||
|
const [yearSummary, setYearSummary] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
category_id: '',
|
||||||
|
amount: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
{ value: 1, label: 'Enero' },
|
||||||
|
{ value: 2, label: 'Febrero' },
|
||||||
|
{ value: 3, label: 'Marzo' },
|
||||||
|
{ value: 4, label: 'Abril' },
|
||||||
|
{ value: 5, label: 'Mayo' },
|
||||||
|
{ value: 6, label: 'Junio' },
|
||||||
|
{ value: 7, label: 'Julio' },
|
||||||
|
{ value: 8, label: 'Agosto' },
|
||||||
|
{ value: 9, label: 'Septiembre' },
|
||||||
|
{ value: 10, label: 'Octubre' },
|
||||||
|
{ value: 11, label: 'Noviembre' },
|
||||||
|
{ value: 12, label: 'Diciembre' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [year, month]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [budgetsData, categoriesData, availableData, summaryData] = await Promise.all([
|
||||||
|
budgetService.getAll({ year, month }),
|
||||||
|
categoryService.getAll(),
|
||||||
|
budgetService.getAvailableCategories(year, month),
|
||||||
|
budgetService.getYearSummary(year),
|
||||||
|
]);
|
||||||
|
setBudgets(budgetsData);
|
||||||
|
setCategories(categoriesData.filter(c => c.type === 'debit'));
|
||||||
|
setAvailableCategories(availableData);
|
||||||
|
setYearSummary(summaryData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading budgets:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...formData,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingBudget) {
|
||||||
|
await budgetService.update(editingBudget.id, data);
|
||||||
|
} else {
|
||||||
|
await budgetService.create(data);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingBudget(null);
|
||||||
|
resetForm();
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving budget:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteBudget) return;
|
||||||
|
try {
|
||||||
|
await budgetService.delete(deleteBudget.id);
|
||||||
|
setDeleteBudget(null);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting budget:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (budget) => {
|
||||||
|
setEditingBudget(budget);
|
||||||
|
setFormData({
|
||||||
|
category_id: budget.category_id,
|
||||||
|
amount: budget.amount,
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToNextMonth = async () => {
|
||||||
|
try {
|
||||||
|
await budgetService.copyToNextMonth(year, month);
|
||||||
|
// Move to next month view
|
||||||
|
if (month === 12) {
|
||||||
|
setYear(year + 1);
|
||||||
|
setMonth(1);
|
||||||
|
} else {
|
||||||
|
setMonth(month + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying budgets:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
category_id: '',
|
||||||
|
amount: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNewBudget = () => {
|
||||||
|
setEditingBudget(null);
|
||||||
|
resetForm();
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressColor = (percentage) => {
|
||||||
|
if (percentage >= 100) return '#ef4444';
|
||||||
|
if (percentage >= 80) return '#f59e0b';
|
||||||
|
if (percentage >= 60) return '#eab308';
|
||||||
|
return '#10b981';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totals = {
|
||||||
|
budgeted: budgets.reduce((sum, b) => sum + parseFloat(b.amount), 0),
|
||||||
|
spent: budgets.reduce((sum, b) => sum + parseFloat(b.spent_amount || 0), 0),
|
||||||
|
};
|
||||||
|
totals.remaining = totals.budgeted - totals.spent;
|
||||||
|
totals.percentage = totals.budgeted > 0 ? (totals.spent / totals.budgeted) * 100 : 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="budgets-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white mb-1 fw-bold">
|
||||||
|
<i className="bi bi-wallet2 me-2"></i>
|
||||||
|
{t('budgets.title')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-400 mb-0 small">{t('budgets.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={handleCopyToNextMonth}
|
||||||
|
title="Copiar presupuestos al próximo mes"
|
||||||
|
>
|
||||||
|
<i className="bi bi-copy me-1"></i>
|
||||||
|
{t('budgets.copyToNext')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={openNewBudget}>
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
{t('budgets.addBudget')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Month/Year Selector */}
|
||||||
|
<div className="card border-0 mb-4" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col-auto">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (month === 1) {
|
||||||
|
setYear(year - 1);
|
||||||
|
setMonth(12);
|
||||||
|
} else {
|
||||||
|
setMonth(month - 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col text-center">
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white me-2"
|
||||||
|
value={month}
|
||||||
|
onChange={(e) => setMonth(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{months.map(m => (
|
||||||
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm d-inline-block w-auto bg-dark border-secondary text-white"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
{[2024, 2025, 2026].map(y => (
|
||||||
|
<option key={y} value={y}>{y}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (month === 12) {
|
||||||
|
setYear(year + 1);
|
||||||
|
setMonth(1);
|
||||||
|
} else {
|
||||||
|
setMonth(month + 1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' }}>
|
||||||
|
<div className="card-body text-white py-3">
|
||||||
|
<small className="opacity-75">{t('budgets.totalBudgeted')}</small>
|
||||||
|
<h4 className="mb-0">{currency(totals.budgeted)}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)' }}>
|
||||||
|
<div className="card-body text-white py-3">
|
||||||
|
<small className="opacity-75">{t('budgets.totalSpent')}</small>
|
||||||
|
<h4 className="mb-0">{currency(totals.spent)}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div
|
||||||
|
className="card border-0"
|
||||||
|
style={{
|
||||||
|
background: totals.remaining >= 0
|
||||||
|
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||||
|
: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body text-white py-3">
|
||||||
|
<small className="opacity-75">{t('budgets.remaining')}</small>
|
||||||
|
<h4 className="mb-0">{currency(totals.remaining)}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body py-3">
|
||||||
|
<small className="text-slate-400">{t('budgets.usage')}</small>
|
||||||
|
<h4 className="mb-1" style={{ color: getProgressColor(totals.percentage) }}>
|
||||||
|
{totals.percentage.toFixed(1)}%
|
||||||
|
</h4>
|
||||||
|
<div className="progress bg-slate-700" style={{ height: '4px' }}>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(totals.percentage, 100)}%`,
|
||||||
|
background: getProgressColor(totals.percentage)
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Budgets List */}
|
||||||
|
{budgets.length === 0 ? (
|
||||||
|
<div className="card border-0 text-center py-5" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-wallet2 text-slate-500" style={{ fontSize: '4rem' }}></i>
|
||||||
|
<h5 className="text-white mt-3">{t('budgets.noBudgets')}</h5>
|
||||||
|
<p className="text-slate-400">{t('budgets.noBudgetsDescription')}</p>
|
||||||
|
<button className="btn btn-primary" onClick={openNewBudget}>
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
{t('budgets.createFirst')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-3">
|
||||||
|
{budgets.map(budget => {
|
||||||
|
const spent = parseFloat(budget.spent_amount || 0);
|
||||||
|
const amount = parseFloat(budget.amount);
|
||||||
|
const percentage = budget.usage_percentage || ((spent / amount) * 100);
|
||||||
|
const remaining = budget.remaining_amount || (amount - spent);
|
||||||
|
const isExceeded = spent > amount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={budget.id} className="col-md-6 col-lg-4">
|
||||||
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<div
|
||||||
|
className="rounded-circle p-2 me-2"
|
||||||
|
style={{ background: `${budget.category?.color || '#3b82f6'}20` }}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${budget.category?.icon || 'bi-tag'}`}
|
||||||
|
style={{ color: budget.category?.color || '#3b82f6' }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 className="text-white mb-0">{budget.category?.name || 'Sin categoría'}</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dropdown">
|
||||||
|
<button
|
||||||
|
className="btn btn-link text-slate-400 p-0"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
>
|
||||||
|
<i className="bi bi-three-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<ul className="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={() => handleEdit(budget)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil me-2"></i>
|
||||||
|
{t('common.edit')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item text-danger"
|
||||||
|
onClick={() => setDeleteBudget(budget)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash me-2"></i>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex justify-content-between mb-1">
|
||||||
|
<span className="text-slate-400 small">{t('budgets.spent')}</span>
|
||||||
|
<span className="text-slate-400 small">{t('budgets.budgeted')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mb-2">
|
||||||
|
<span className={`fw-bold ${isExceeded ? 'text-danger' : 'text-white'}`}>
|
||||||
|
{currency(spent)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white">{currency(amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress bg-slate-700" style={{ height: '8px' }}>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(percentage, 100)}%`,
|
||||||
|
background: getProgressColor(percentage)
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<small className="text-slate-400 d-block">{t('budgets.remaining')}</small>
|
||||||
|
<span className={`fw-bold ${remaining >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{currency(remaining)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-end">
|
||||||
|
<small className="text-slate-400 d-block">{t('budgets.usage')}</small>
|
||||||
|
<span
|
||||||
|
className="fw-bold"
|
||||||
|
style={{ color: getProgressColor(percentage) }}
|
||||||
|
>
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
{isExceeded && (
|
||||||
|
<div className="alert alert-danger py-2 mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<i className="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
{t('budgets.exceeded')} {currency(Math.abs(remaining))}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isExceeded && percentage >= 80 && (
|
||||||
|
<div className="alert alert-warning py-2 mt-3 mb-0">
|
||||||
|
<small>
|
||||||
|
<i className="bi bi-exclamation-circle me-1"></i>
|
||||||
|
{t('budgets.almostExceeded')}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Year Summary */}
|
||||||
|
{yearSummary && yearSummary.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h5 className="text-white mb-3">
|
||||||
|
<i className="bi bi-calendar3 me-2"></i>
|
||||||
|
{t('budgets.yearSummary')} {year}
|
||||||
|
</h5>
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-dark table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('budgets.month')}</th>
|
||||||
|
<th className="text-end">{t('budgets.budgeted')}</th>
|
||||||
|
<th className="text-end">{t('budgets.spent')}</th>
|
||||||
|
<th className="text-end">{t('budgets.remaining')}</th>
|
||||||
|
<th className="text-end">{t('budgets.usage')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{yearSummary.map(item => {
|
||||||
|
const monthName = months.find(m => m.value === item.month)?.label || item.month;
|
||||||
|
const isCurrentMonth = item.month === new Date().getMonth() + 1 && year === new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.month}
|
||||||
|
className={isCurrentMonth ? 'table-primary' : ''}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setMonth(item.month)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{monthName}
|
||||||
|
{isCurrentMonth && (
|
||||||
|
<span className="badge bg-primary ms-2">Actual</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{currency(item.budgeted)}</td>
|
||||||
|
<td className="text-end text-danger">{currency(item.spent)}</td>
|
||||||
|
<td className={`text-end ${item.remaining >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{currency(item.remaining)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<span
|
||||||
|
className="badge"
|
||||||
|
style={{
|
||||||
|
background: getProgressColor(item.percentage),
|
||||||
|
minWidth: '60px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.percentage.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Budget Form Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered modal-sm">
|
||||||
|
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="modal-header border-0">
|
||||||
|
<h5 className="modal-title text-white">
|
||||||
|
<i className={`bi ${editingBudget ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||||
|
{editingBudget ? t('budgets.editBudget') : t('budgets.newBudget')}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="text-slate-400 small mb-3">
|
||||||
|
{months.find(m => m.value === month)?.label} {year}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('budgets.category')} *</label>
|
||||||
|
<select
|
||||||
|
className="form-select bg-dark border-secondary text-white"
|
||||||
|
value={formData.category_id}
|
||||||
|
onChange={(e) => setFormData({...formData, category_id: e.target.value})}
|
||||||
|
required
|
||||||
|
disabled={editingBudget}
|
||||||
|
>
|
||||||
|
<option value="">{t('budgets.selectCategory')}</option>
|
||||||
|
{(editingBudget ? categories : availableCategories).map(cat => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('budgets.amount')} *</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<span className="input-group-text bg-dark border-secondary text-white">€</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.amount}
|
||||||
|
onChange={(e) => setFormData({...formData, amount: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer border-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<i className="bi bi-check-lg me-1"></i>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
show={!!deleteBudget}
|
||||||
|
onClose={() => setDeleteBudget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title={t('budgets.deleteBudget')}
|
||||||
|
message={t('budgets.deleteConfirm', { category: deleteBudget?.category?.name })}
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Budgets;
|
||||||
418
frontend/src/pages/FinancialHealth.jsx
Normal file
418
frontend/src/pages/FinancialHealth.jsx
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { financialHealthService } from '../services/api';
|
||||||
|
import useFormatters from '../hooks/useFormatters';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
const FinancialHealth = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { currency } = useFormatters();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [history, setHistory] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [healthData, historyData] = await Promise.all([
|
||||||
|
financialHealthService.get(),
|
||||||
|
financialHealthService.getHistory({ months: 6 })
|
||||||
|
]);
|
||||||
|
setData(healthData);
|
||||||
|
setHistory(historyData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading financial health:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreColor = (score) => {
|
||||||
|
if (score >= 80) return '#10b981';
|
||||||
|
if (score >= 60) return '#22c55e';
|
||||||
|
if (score >= 40) return '#f59e0b';
|
||||||
|
if (score >= 20) return '#f97316';
|
||||||
|
return '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScoreLabel = (score) => {
|
||||||
|
if (score >= 80) return t('financialHealth.excellent');
|
||||||
|
if (score >= 60) return t('financialHealth.good');
|
||||||
|
if (score >= 40) return t('financialHealth.regular');
|
||||||
|
if (score >= 20) return t('financialHealth.bad');
|
||||||
|
return t('financialHealth.critical');
|
||||||
|
};
|
||||||
|
|
||||||
|
const metricConfigs = {
|
||||||
|
savingsCapacity: {
|
||||||
|
icon: 'bi-piggy-bank',
|
||||||
|
color: '#10b981',
|
||||||
|
gradient: 'linear-gradient(135deg, #059669 0%, #10b981 100%)',
|
||||||
|
},
|
||||||
|
debtControl: {
|
||||||
|
icon: 'bi-credit-card',
|
||||||
|
color: '#3b82f6',
|
||||||
|
gradient: 'linear-gradient(135deg, #2563eb 0%, #3b82f6 100%)',
|
||||||
|
},
|
||||||
|
budgetManagement: {
|
||||||
|
icon: 'bi-wallet2',
|
||||||
|
color: '#8b5cf6',
|
||||||
|
gradient: 'linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%)',
|
||||||
|
},
|
||||||
|
investments: {
|
||||||
|
icon: 'bi-graph-up-arrow',
|
||||||
|
color: '#f59e0b',
|
||||||
|
gradient: 'linear-gradient(135deg, #d97706 0%, #f59e0b 100%)',
|
||||||
|
},
|
||||||
|
emergencyFund: {
|
||||||
|
icon: 'bi-shield-check',
|
||||||
|
color: '#06b6d4',
|
||||||
|
gradient: 'linear-gradient(135deg, #0891b2 0%, #06b6d4 100%)',
|
||||||
|
},
|
||||||
|
futurePlanning: {
|
||||||
|
icon: 'bi-calendar-check',
|
||||||
|
color: '#ec4899',
|
||||||
|
gradient: 'linear-gradient(135deg, #db2777 0%, #ec4899 100%)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInsightIcon = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return 'bi-check-circle-fill';
|
||||||
|
case 'warning': return 'bi-exclamation-triangle-fill';
|
||||||
|
case 'danger': return 'bi-x-circle-fill';
|
||||||
|
case 'info': return 'bi-info-circle-fill';
|
||||||
|
default: return 'bi-lightbulb-fill';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInsightColor = (type) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return '#10b981';
|
||||||
|
case 'warning': return '#f59e0b';
|
||||||
|
case 'danger': return '#ef4444';
|
||||||
|
case 'info': return '#3b82f6';
|
||||||
|
default: return '#8b5cf6';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<i className="bi bi-exclamation-triangle me-2"></i>
|
||||||
|
No se pudo cargar la información de salud financiera.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = data.score;
|
||||||
|
const scoreColor = getScoreColor(score);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="financial-health-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white mb-1 fw-bold">
|
||||||
|
<i className="bi bi-heart-pulse me-2"></i>
|
||||||
|
{t('financialHealth.title')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-400 mb-0 small">{t('financialHealth.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-outline-primary btn-sm" onClick={loadData}>
|
||||||
|
<i className="bi bi-arrow-clockwise me-1"></i>
|
||||||
|
{t('common.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row g-4">
|
||||||
|
{/* Score Circle */}
|
||||||
|
<div className="col-lg-4">
|
||||||
|
<div className="card border-0 h-100" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body text-center py-5">
|
||||||
|
{/* Score Ring */}
|
||||||
|
<div className="position-relative d-inline-block mb-4">
|
||||||
|
<svg width="200" height="200" viewBox="0 0 200 200">
|
||||||
|
{/* Background circle */}
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="85"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1e293b"
|
||||||
|
strokeWidth="15"
|
||||||
|
/>
|
||||||
|
{/* Progress circle */}
|
||||||
|
<circle
|
||||||
|
cx="100"
|
||||||
|
cy="100"
|
||||||
|
r="85"
|
||||||
|
fill="none"
|
||||||
|
stroke={scoreColor}
|
||||||
|
strokeWidth="15"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${(score / 100) * 534} 534`}
|
||||||
|
transform="rotate(-90 100 100)"
|
||||||
|
style={{
|
||||||
|
filter: `drop-shadow(0 0 10px ${scoreColor}50)`,
|
||||||
|
transition: 'stroke-dasharray 1s ease-in-out'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Score text */}
|
||||||
|
<text
|
||||||
|
x="100"
|
||||||
|
y="90"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={scoreColor}
|
||||||
|
fontSize="48"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x="100"
|
||||||
|
y="115"
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#94a3b8"
|
||||||
|
fontSize="16"
|
||||||
|
>
|
||||||
|
de 100
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 className="text-white mb-2">{getScoreLabel(score)}</h5>
|
||||||
|
<p className="text-slate-400 small mb-0">
|
||||||
|
{t('financialHealth.scoreDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* History Chart */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="mt-4" style={{ height: '100px' }}>
|
||||||
|
<Line
|
||||||
|
data={{
|
||||||
|
labels: history.map(h => h.month),
|
||||||
|
datasets: [{
|
||||||
|
data: history.map(h => h.score),
|
||||||
|
borderColor: scoreColor,
|
||||||
|
backgroundColor: `${scoreColor}20`,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.4,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: scoreColor,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: { display: false, min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Grid */}
|
||||||
|
<div className="col-lg-8">
|
||||||
|
<div className="row g-3">
|
||||||
|
{Object.entries(data.metrics).map(([key, metric]) => {
|
||||||
|
const config = metricConfigs[key];
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="col-md-6 col-lg-4">
|
||||||
|
<div
|
||||||
|
className="card border-0 h-100"
|
||||||
|
style={{ background: config.gradient }}
|
||||||
|
>
|
||||||
|
<div className="card-body text-white">
|
||||||
|
<div className="d-flex align-items-center mb-2">
|
||||||
|
<i className={`bi ${config.icon} fs-4 me-2 opacity-75`}></i>
|
||||||
|
<span className="small opacity-75">
|
||||||
|
{t(`financialHealth.metrics.${key}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-1">{metric.score}<small className="fs-6 opacity-75">/100</small></h3>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="progress bg-white bg-opacity-25" style={{ height: '4px' }}>
|
||||||
|
<div
|
||||||
|
className="progress-bar bg-white"
|
||||||
|
style={{ width: `${metric.score}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value if available */}
|
||||||
|
{metric.value !== undefined && (
|
||||||
|
<small className="opacity-75 mt-2 d-block">
|
||||||
|
{typeof metric.value === 'number' && key !== 'emergencyFund'
|
||||||
|
? `${metric.value}%`
|
||||||
|
: key === 'emergencyFund'
|
||||||
|
? `${metric.value} meses`
|
||||||
|
: metric.value
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-lightbulb me-2"></i>
|
||||||
|
{t('financialHealth.insights')}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="row g-3">
|
||||||
|
{data.insights && data.insights.map((insight, index) => (
|
||||||
|
<div key={index} className="col-md-6">
|
||||||
|
<div
|
||||||
|
className="d-flex p-3 rounded"
|
||||||
|
style={{
|
||||||
|
background: '#1e293b',
|
||||||
|
borderLeft: `3px solid ${getInsightColor(insight.type)}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${getInsightIcon(insight.type)} me-3 fs-5`}
|
||||||
|
style={{ color: getInsightColor(insight.type) }}
|
||||||
|
></i>
|
||||||
|
<div>
|
||||||
|
<p className="text-white mb-0 small">{insight.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{data.recommendations && data.recommendations.length > 0 && (
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-magic me-2"></i>
|
||||||
|
{t('financialHealth.recommendations')}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{data.recommendations.map((rec, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="badge py-2 px-3"
|
||||||
|
style={{
|
||||||
|
background: '#0f172a',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '0.85rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-arrow-right me-2 text-primary"></i>
|
||||||
|
{rec}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body py-3">
|
||||||
|
<small className="text-slate-400 d-block">{t('financialHealth.totalBalance')}</small>
|
||||||
|
<h5 className={`mb-0 ${data.totals?.balance >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{currency(data.totals?.balance || 0)}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body py-3">
|
||||||
|
<small className="text-slate-400 d-block">{t('financialHealth.monthlyIncome')}</small>
|
||||||
|
<h5 className="text-success mb-0">{currency(data.totals?.income || 0)}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body py-3">
|
||||||
|
<small className="text-slate-400 d-block">{t('financialHealth.monthlyExpenses')}</small>
|
||||||
|
<h5 className="text-danger mb-0">{currency(data.totals?.expense || 0)}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body py-3">
|
||||||
|
<small className="text-slate-400 d-block">{t('financialHealth.savingsRate')}</small>
|
||||||
|
<h5 className="text-primary mb-0">{data.totals?.savings_rate || 0}%</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FinancialHealth;
|
||||||
657
frontend/src/pages/Goals.jsx
Normal file
657
frontend/src/pages/Goals.jsx
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { financialGoalService } from '../services/api';
|
||||||
|
import useFormatters from '../hooks/useFormatters';
|
||||||
|
import ConfirmModal from '../components/ConfirmModal';
|
||||||
|
|
||||||
|
const Goals = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { currency, formatDate } = useFormatters();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [goals, setGoals] = useState([]);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [showContributeModal, setShowContributeModal] = useState(false);
|
||||||
|
const [editingGoal, setEditingGoal] = useState(null);
|
||||||
|
const [contributingGoal, setContributingGoal] = useState(null);
|
||||||
|
const [deleteGoal, setDeleteGoal] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
target_amount: '',
|
||||||
|
current_amount: '',
|
||||||
|
target_date: '',
|
||||||
|
monthly_contribution: '',
|
||||||
|
color: '#3b82f6',
|
||||||
|
icon: 'bi-piggy-bank',
|
||||||
|
});
|
||||||
|
const [contributeAmount, setContributeAmount] = useState('');
|
||||||
|
const [contributeNote, setContributeNote] = useState('');
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
'bi-piggy-bank', 'bi-house', 'bi-car-front', 'bi-airplane', 'bi-laptop',
|
||||||
|
'bi-phone', 'bi-gift', 'bi-mortarboard', 'bi-heart', 'bi-gem',
|
||||||
|
'bi-currency-dollar', 'bi-wallet2', 'bi-safe', 'bi-shield-check', 'bi-lightning',
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGoals();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadGoals = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await financialGoalService.getAll();
|
||||||
|
setGoals(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading goals:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (editingGoal) {
|
||||||
|
await financialGoalService.update(editingGoal.id, formData);
|
||||||
|
} else {
|
||||||
|
await financialGoalService.create(formData);
|
||||||
|
}
|
||||||
|
setShowModal(false);
|
||||||
|
setEditingGoal(null);
|
||||||
|
resetForm();
|
||||||
|
loadGoals();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving goal:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContribute = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!contributingGoal || !contributeAmount) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await financialGoalService.addContribution(contributingGoal.id, {
|
||||||
|
amount: parseFloat(contributeAmount),
|
||||||
|
notes: contributeNote || null,
|
||||||
|
});
|
||||||
|
setShowContributeModal(false);
|
||||||
|
setContributingGoal(null);
|
||||||
|
setContributeAmount('');
|
||||||
|
setContributeNote('');
|
||||||
|
loadGoals();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding contribution:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteGoal) return;
|
||||||
|
try {
|
||||||
|
await financialGoalService.delete(deleteGoal.id);
|
||||||
|
setDeleteGoal(null);
|
||||||
|
loadGoals();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting goal:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (goal) => {
|
||||||
|
setEditingGoal(goal);
|
||||||
|
setFormData({
|
||||||
|
name: goal.name,
|
||||||
|
description: goal.description || '',
|
||||||
|
target_amount: goal.target_amount,
|
||||||
|
current_amount: goal.current_amount,
|
||||||
|
target_date: goal.target_date || '',
|
||||||
|
monthly_contribution: goal.monthly_contribution || '',
|
||||||
|
color: goal.color || '#3b82f6',
|
||||||
|
icon: goal.icon || 'bi-piggy-bank',
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = async (goal, status) => {
|
||||||
|
try {
|
||||||
|
await financialGoalService.update(goal.id, { ...goal, status });
|
||||||
|
loadGoals();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
target_amount: '',
|
||||||
|
current_amount: '',
|
||||||
|
target_date: '',
|
||||||
|
monthly_contribution: '',
|
||||||
|
color: '#3b82f6',
|
||||||
|
icon: 'bi-piggy-bank',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openNewGoal = () => {
|
||||||
|
setEditingGoal(null);
|
||||||
|
resetForm();
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status) => {
|
||||||
|
const statusConfig = {
|
||||||
|
active: { bg: 'bg-primary', label: t('goals.statusActive') },
|
||||||
|
completed: { bg: 'bg-success', label: t('goals.statusCompleted') },
|
||||||
|
paused: { bg: 'bg-warning', label: t('goals.statusPaused') },
|
||||||
|
cancelled: { bg: 'bg-secondary', label: t('goals.statusCancelled') },
|
||||||
|
};
|
||||||
|
const config = statusConfig[status] || statusConfig.active;
|
||||||
|
return <span className={`badge ${config.bg}`}>{config.label}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stats calculation
|
||||||
|
const stats = {
|
||||||
|
total: goals.length,
|
||||||
|
active: goals.filter(g => g.status === 'active').length,
|
||||||
|
completed: goals.filter(g => g.status === 'completed').length,
|
||||||
|
totalTarget: goals.reduce((sum, g) => sum + parseFloat(g.target_amount), 0),
|
||||||
|
totalCurrent: goals.reduce((sum, g) => sum + parseFloat(g.current_amount), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: '400px' }}>
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="goals-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white mb-1 fw-bold">
|
||||||
|
<i className="bi bi-flag me-2"></i>
|
||||||
|
{t('goals.title')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-400 mb-0 small">{t('goals.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" onClick={openNewGoal}>
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
{t('goals.addGoal')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="row g-3 mb-4">
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body text-center py-3">
|
||||||
|
<small className="text-slate-400">{t('goals.totalGoals')}</small>
|
||||||
|
<h4 className="text-white mb-0">{stats.total}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body text-center py-3">
|
||||||
|
<small className="text-slate-400">{t('goals.activeGoals')}</small>
|
||||||
|
<h4 className="text-primary mb-0">{stats.active}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body text-center py-3">
|
||||||
|
<small className="text-slate-400">{t('goals.totalSaved')}</small>
|
||||||
|
<h4 className="text-success mb-0">{currency(stats.totalCurrent)}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body text-center py-3">
|
||||||
|
<small className="text-slate-400">{t('goals.remaining')}</small>
|
||||||
|
<h4 className="text-warning mb-0">{currency(stats.totalTarget - stats.totalCurrent)}</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goals Grid */}
|
||||||
|
{goals.length === 0 ? (
|
||||||
|
<div className="card border-0 text-center py-5" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<i className="bi bi-flag text-slate-500" style={{ fontSize: '4rem' }}></i>
|
||||||
|
<h5 className="text-white mt-3">{t('goals.noGoals')}</h5>
|
||||||
|
<p className="text-slate-400">{t('goals.noGoalsDescription')}</p>
|
||||||
|
<button className="btn btn-primary" onClick={openNewGoal}>
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
{t('goals.createFirstGoal')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="row g-4">
|
||||||
|
{goals.map(goal => {
|
||||||
|
const progress = goal.progress_percentage ||
|
||||||
|
((goal.current_amount / goal.target_amount) * 100);
|
||||||
|
const remaining = goal.remaining_amount ||
|
||||||
|
(goal.target_amount - goal.current_amount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={goal.id} className="col-md-6 col-lg-4">
|
||||||
|
<div
|
||||||
|
className="card border-0 h-100"
|
||||||
|
style={{
|
||||||
|
background: '#0f172a',
|
||||||
|
borderTop: `3px solid ${goal.color || '#3b82f6'}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex align-items-start justify-content-between mb-3">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<div
|
||||||
|
className="rounded-circle p-2 me-2"
|
||||||
|
style={{ background: `${goal.color || '#3b82f6'}20` }}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={`bi ${goal.icon || 'bi-piggy-bank'} fs-5`}
|
||||||
|
style={{ color: goal.color || '#3b82f6' }}
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 className="text-white mb-0">{goal.name}</h6>
|
||||||
|
{goal.description && (
|
||||||
|
<small className="text-slate-400">{goal.description}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(goal.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="d-flex justify-content-between mb-1">
|
||||||
|
<span className="text-success fw-bold">{currency(goal.current_amount)}</span>
|
||||||
|
<span className="text-slate-400">{currency(goal.target_amount)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress bg-slate-700" style={{ height: '8px' }}>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(progress, 100)}%`,
|
||||||
|
background: goal.color || '#3b82f6'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mt-1">
|
||||||
|
<small className="text-slate-400">{progress.toFixed(1)}%</small>
|
||||||
|
<small className="text-slate-400">
|
||||||
|
{t('goals.remaining')}: {currency(remaining)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mb-3">
|
||||||
|
{goal.target_date && (
|
||||||
|
<div className="d-flex justify-content-between small mb-1">
|
||||||
|
<span className="text-slate-400">{t('goals.targetDate')}</span>
|
||||||
|
<span className="text-white">{formatDate(goal.target_date)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{goal.monthly_contribution > 0 && (
|
||||||
|
<div className="d-flex justify-content-between small mb-1">
|
||||||
|
<span className="text-slate-400">{t('goals.monthlyContribution')}</span>
|
||||||
|
<span className="text-white">{currency(goal.monthly_contribution)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{goal.months_remaining > 0 && (
|
||||||
|
<div className="d-flex justify-content-between small">
|
||||||
|
<span className="text-slate-400">{t('goals.monthsRemaining')}</span>
|
||||||
|
<span className="text-white">{goal.months_remaining} {t('goals.months')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* On Track Indicator */}
|
||||||
|
{goal.status === 'active' && goal.is_on_track !== undefined && (
|
||||||
|
<div className={`alert ${goal.is_on_track ? 'alert-success' : 'alert-warning'} py-2 mb-3`}>
|
||||||
|
<small>
|
||||||
|
<i className={`bi ${goal.is_on_track ? 'bi-check-circle' : 'bi-exclamation-triangle'} me-1`}></i>
|
||||||
|
{goal.is_on_track
|
||||||
|
? t('goals.onTrack')
|
||||||
|
: t('goals.needsMore', { amount: currency(goal.required_monthly_saving || 0) })
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
{goal.status === 'active' && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm flex-grow-1"
|
||||||
|
onClick={() => {
|
||||||
|
setContributingGoal(goal);
|
||||||
|
setShowContributeModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="bi bi-plus-circle me-1"></i>
|
||||||
|
{t('goals.contribute')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm"
|
||||||
|
onClick={() => handleEdit(goal)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<div className="dropdown">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
>
|
||||||
|
<i className="bi bi-three-dots-vertical"></i>
|
||||||
|
</button>
|
||||||
|
<ul className="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
||||||
|
{goal.status === 'active' && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={() => handleStatusChange(goal, 'paused')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-pause-circle me-2"></i>
|
||||||
|
{t('goals.pause')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{goal.status === 'paused' && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item"
|
||||||
|
onClick={() => handleStatusChange(goal, 'active')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-play-circle me-2"></i>
|
||||||
|
{t('goals.resume')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{goal.status !== 'completed' && (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item text-success"
|
||||||
|
onClick={() => handleStatusChange(goal, 'completed')}
|
||||||
|
>
|
||||||
|
<i className="bi bi-check-circle me-2"></i>
|
||||||
|
{t('goals.markCompleted')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li><hr className="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="dropdown-item text-danger"
|
||||||
|
onClick={() => setDeleteGoal(goal)}
|
||||||
|
>
|
||||||
|
<i className="bi bi-trash me-2"></i>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Goal Form Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered">
|
||||||
|
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="modal-header border-0">
|
||||||
|
<h5 className="modal-title text-white">
|
||||||
|
<i className={`bi ${editingGoal ? 'bi-pencil' : 'bi-plus-circle'} me-2`}></i>
|
||||||
|
{editingGoal ? t('goals.editGoal') : t('goals.newGoal')}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.name')} *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.description')}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
rows="2"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Amount */}
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.targetAmount')} *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.target_amount}
|
||||||
|
onChange={(e) => setFormData({...formData, target_amount: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.currentAmount')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.current_amount}
|
||||||
|
onChange={(e) => setFormData({...formData, current_amount: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Date & Monthly */}
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.targetDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.target_date}
|
||||||
|
onChange={(e) => setFormData({...formData, target_date: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-6">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.monthlyContribution')}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={formData.monthly_contribution}
|
||||||
|
onChange={(e) => setFormData({...formData, monthly_contribution: e.target.value})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Selection */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.icon')}</label>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{icons.map(icon => (
|
||||||
|
<button
|
||||||
|
key={icon}
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-sm ${formData.icon === icon ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setFormData({...formData, icon})}
|
||||||
|
>
|
||||||
|
<i className={`bi ${icon}`}></i>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Selection */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.color')}</label>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{colors.map(color => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm p-0"
|
||||||
|
style={{
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
background: color,
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: formData.color === color ? '3px solid white' : 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => setFormData({...formData, color})}
|
||||||
|
></button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer border-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<i className="bi bi-check-lg me-1"></i>
|
||||||
|
{t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contribute Modal */}
|
||||||
|
{showContributeModal && contributingGoal && (
|
||||||
|
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.7)' }}>
|
||||||
|
<div className="modal-dialog modal-dialog-centered modal-sm">
|
||||||
|
<div className="modal-content border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="modal-header border-0">
|
||||||
|
<h5 className="modal-title text-white">
|
||||||
|
<i className="bi bi-plus-circle me-2"></i>
|
||||||
|
{t('goals.contribute')}
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-close btn-close-white"
|
||||||
|
onClick={() => setShowContributeModal(false)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleContribute}>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="text-center mb-3">
|
||||||
|
<i
|
||||||
|
className={`bi ${contributingGoal.icon || 'bi-piggy-bank'} fs-1`}
|
||||||
|
style={{ color: contributingGoal.color || '#3b82f6' }}
|
||||||
|
></i>
|
||||||
|
<h6 className="text-white mt-2">{contributingGoal.name}</h6>
|
||||||
|
<small className="text-slate-400">
|
||||||
|
{currency(contributingGoal.current_amount)} de {currency(contributingGoal.target_amount)}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.contributeAmount')} *</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
className="form-control bg-dark border-secondary text-white text-center fs-4"
|
||||||
|
value={contributeAmount}
|
||||||
|
onChange={(e) => setContributeAmount(e.target.value)}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label text-slate-400">{t('goals.notes')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control bg-dark border-secondary text-white"
|
||||||
|
value={contributeNote}
|
||||||
|
onChange={(e) => setContributeNote(e.target.value)}
|
||||||
|
placeholder={t('goals.notesPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer border-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => setShowContributeModal(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-success">
|
||||||
|
<i className="bi bi-plus-lg me-1"></i>
|
||||||
|
{t('goals.addContribution')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<ConfirmModal
|
||||||
|
show={!!deleteGoal}
|
||||||
|
onClose={() => setDeleteGoal(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title={t('goals.deleteGoal')}
|
||||||
|
message={t('goals.deleteConfirm', { name: deleteGoal?.name })}
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Goals;
|
||||||
834
frontend/src/pages/Reports.jsx
Normal file
834
frontend/src/pages/Reports.jsx
Normal file
@ -0,0 +1,834 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { reportService, categoryService } from '../services/api';
|
||||||
|
import useFormatters from '../hooks/useFormatters';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Bar, Line, Doughnut, Pie } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
const Reports = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { currency } = useFormatters();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('summary');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [year, setYear] = useState(new Date().getFullYear());
|
||||||
|
const [months, setMonths] = useState(12);
|
||||||
|
|
||||||
|
// Data states
|
||||||
|
const [summary, setSummary] = useState(null);
|
||||||
|
const [categoryData, setCategoryData] = useState(null);
|
||||||
|
const [evolutionData, setEvolutionData] = useState(null);
|
||||||
|
const [dayOfWeekData, setDayOfWeekData] = useState(null);
|
||||||
|
const [topExpenses, setTopExpenses] = useState(null);
|
||||||
|
const [projection, setProjection] = useState(null);
|
||||||
|
const [comparison, setComparison] = useState(null);
|
||||||
|
|
||||||
|
// Load data based on active tab
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'summary':
|
||||||
|
const summaryRes = await reportService.getSummary({ year });
|
||||||
|
setSummary(summaryRes);
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
const catRes = await reportService.getByCategory({ type: 'debit' });
|
||||||
|
setCategoryData(catRes);
|
||||||
|
break;
|
||||||
|
case 'evolution':
|
||||||
|
const evoRes = await reportService.getMonthlyEvolution({ months });
|
||||||
|
setEvolutionData(evoRes);
|
||||||
|
break;
|
||||||
|
case 'dayOfWeek':
|
||||||
|
const dowRes = await reportService.getByDayOfWeek({ months: 6 });
|
||||||
|
setDayOfWeekData(dowRes);
|
||||||
|
break;
|
||||||
|
case 'topExpenses':
|
||||||
|
const topRes = await reportService.getTopExpenses({ limit: 20 });
|
||||||
|
setTopExpenses(topRes);
|
||||||
|
break;
|
||||||
|
case 'projection':
|
||||||
|
const projRes = await reportService.getProjection();
|
||||||
|
setProjection(projRes);
|
||||||
|
break;
|
||||||
|
case 'comparison':
|
||||||
|
const compRes = await reportService.comparePeriods();
|
||||||
|
setComparison(compRes);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading report data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeTab, year, months]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'summary', label: t('reports.summary'), icon: 'bi-clipboard-data' },
|
||||||
|
{ id: 'category', label: t('reports.byCategory'), icon: 'bi-pie-chart' },
|
||||||
|
{ id: 'evolution', label: t('reports.monthlyEvolution'), icon: 'bi-graph-up' },
|
||||||
|
{ id: 'comparison', label: t('reports.comparison'), icon: 'bi-arrow-left-right' },
|
||||||
|
{ id: 'topExpenses', label: t('reports.topExpenses'), icon: 'bi-sort-down' },
|
||||||
|
{ id: 'projection', label: t('reports.projection'), icon: 'bi-lightning' },
|
||||||
|
{ id: 'dayOfWeek', label: 'Por día', icon: 'bi-calendar-week' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Chart options
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: { color: '#94a3b8' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#94a3b8' },
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#94a3b8' },
|
||||||
|
grid: { color: 'rgba(148, 163, 184, 0.1)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doughnutOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: { color: '#94a3b8', padding: 15, font: { size: 11 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Summary Tab
|
||||||
|
const renderSummary = () => {
|
||||||
|
if (!summary) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
{/* Year Selector */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="d-flex gap-2 mb-3">
|
||||||
|
{[2024, 2025].map(y => (
|
||||||
|
<button
|
||||||
|
key={y}
|
||||||
|
className={`btn btn-sm ${year === y ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setYear(y)}
|
||||||
|
>
|
||||||
|
{y}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #059669 0%, #047857 100%)' }}>
|
||||||
|
<div className="card-body text-white">
|
||||||
|
<h6 className="opacity-75">{t('reports.income')} {year}</h6>
|
||||||
|
<h3 className="mb-2">{currency(summary.current.income)}</h3>
|
||||||
|
{summary.variation.income !== 0 && (
|
||||||
|
<span className={`badge ${summary.variation.income >= 0 ? 'bg-success' : 'bg-danger'}`}>
|
||||||
|
<i className={`bi bi-arrow-${summary.variation.income >= 0 ? 'up' : 'down'} me-1`}></i>
|
||||||
|
{Math.abs(summary.variation.income)}% {t('reports.vsLastPeriod')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)' }}>
|
||||||
|
<div className="card-body text-white">
|
||||||
|
<h6 className="opacity-75">{t('reports.expenses')} {year}</h6>
|
||||||
|
<h3 className="mb-2">{currency(summary.current.expense)}</h3>
|
||||||
|
{summary.variation.expense !== 0 && (
|
||||||
|
<span className={`badge ${summary.variation.expense <= 0 ? 'bg-success' : 'bg-danger'}`}>
|
||||||
|
<i className={`bi bi-arrow-${summary.variation.expense >= 0 ? 'up' : 'down'} me-1`}></i>
|
||||||
|
{Math.abs(summary.variation.expense)}% {t('reports.vsLastPeriod')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card border-0 h-100" style={{ background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' }}>
|
||||||
|
<div className="card-body text-white">
|
||||||
|
<h6 className="opacity-75">{t('reports.balance')} {year}</h6>
|
||||||
|
<h3 className="mb-2">{currency(summary.current.balance)}</h3>
|
||||||
|
<span className="small opacity-75">
|
||||||
|
Tasa de ahorro: {summary.current.income > 0
|
||||||
|
? ((summary.current.balance / summary.current.income) * 100).toFixed(1)
|
||||||
|
: 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison Chart */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-bar-chart me-2"></i>
|
||||||
|
Comparativa Anual
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '300px' }}>
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels: ['Ingresos', 'Gastos', 'Balance'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: String(year - 1),
|
||||||
|
data: [summary.previous.income, summary.previous.expense, summary.previous.balance],
|
||||||
|
backgroundColor: 'rgba(148, 163, 184, 0.5)',
|
||||||
|
borderColor: '#94a3b8',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: String(year),
|
||||||
|
data: [summary.current.income, summary.current.expense, summary.current.balance],
|
||||||
|
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)', 'rgba(59, 130, 246, 0.7)'],
|
||||||
|
borderColor: ['#10b981', '#ef4444', '#3b82f6'],
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
options={chartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Category Tab
|
||||||
|
const renderCategory = () => {
|
||||||
|
if (!categoryData) return null;
|
||||||
|
|
||||||
|
const colors = categoryData.data.map((_, i) =>
|
||||||
|
`hsl(${(i * 360) / categoryData.data.length}, 70%, 50%)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-pie-chart me-2"></i>
|
||||||
|
Distribución de Gastos
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '400px' }}>
|
||||||
|
<Doughnut
|
||||||
|
data={{
|
||||||
|
labels: categoryData.data.map(c => c.category_name),
|
||||||
|
datasets: [{
|
||||||
|
data: categoryData.data.map(c => c.total),
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={doughnutOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent d-flex justify-content-between">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-list-ol me-2"></i>
|
||||||
|
Detalle por Categoría
|
||||||
|
</h6>
|
||||||
|
<span className="text-success fw-bold">{currency(categoryData.total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||||
|
<table className="table table-dark table-hover mb-0">
|
||||||
|
<thead className="sticky-top" style={{ background: '#1e293b' }}>
|
||||||
|
<tr>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th className="text-end">Total</th>
|
||||||
|
<th className="text-end">%</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categoryData.data.map((cat, i) => (
|
||||||
|
<tr key={cat.category_id}>
|
||||||
|
<td>
|
||||||
|
<i className={`bi ${cat.icon} me-2`} style={{ color: colors[i] }}></i>
|
||||||
|
{cat.category_name}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">{currency(cat.total)}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<span className="badge bg-secondary">{cat.percentage}%</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Evolution Tab
|
||||||
|
const renderEvolution = () => {
|
||||||
|
if (!evolutionData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="d-flex gap-2 mb-3">
|
||||||
|
{[6, 12, 24].map(m => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
className={`btn btn-sm ${months === m ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setMonths(m)}
|
||||||
|
>
|
||||||
|
{m} meses
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Averages Cards */}
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<small className="text-slate-400">{t('reports.avgIncome')}</small>
|
||||||
|
<h5 className="text-success mb-0">{currency(evolutionData.averages.income)}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<small className="text-slate-400">{t('reports.avgExpense')}</small>
|
||||||
|
<h5 className="text-danger mb-0">{currency(evolutionData.averages.expense)}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<small className="text-slate-400">{t('reports.balance')}</small>
|
||||||
|
<h5 className={`mb-0 ${evolutionData.averages.balance >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||||
|
{currency(evolutionData.averages.balance)}
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-3">
|
||||||
|
<div className="card border-0 text-center" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body">
|
||||||
|
<small className="text-slate-400">{t('reports.savingsRate')}</small>
|
||||||
|
<h5 className="text-primary mb-0">{evolutionData.averages.savings_rate}%</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Evolution Chart */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-graph-up me-2"></i>
|
||||||
|
{t('reports.monthlyEvolution')}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '350px' }}>
|
||||||
|
<Line
|
||||||
|
data={{
|
||||||
|
labels: evolutionData.data.map(d => d.month_label),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: t('reports.income'),
|
||||||
|
data: evolutionData.data.map(d => d.income),
|
||||||
|
borderColor: '#10b981',
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('reports.expenses'),
|
||||||
|
data: evolutionData.data.map(d => d.expense),
|
||||||
|
borderColor: '#ef4444',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('reports.balance'),
|
||||||
|
data: evolutionData.data.map(d => d.balance),
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
options={chartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Savings Rate Chart */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-percent me-2"></i>
|
||||||
|
{t('reports.savingsRate')} por mes
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '250px' }}>
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels: evolutionData.data.map(d => d.month_label),
|
||||||
|
datasets: [{
|
||||||
|
label: t('reports.savingsRate'),
|
||||||
|
data: evolutionData.data.map(d => d.savings_rate),
|
||||||
|
backgroundColor: evolutionData.data.map(d =>
|
||||||
|
d.savings_rate >= 20 ? 'rgba(16, 185, 129, 0.7)' :
|
||||||
|
d.savings_rate >= 10 ? 'rgba(245, 158, 11, 0.7)' :
|
||||||
|
d.savings_rate >= 0 ? 'rgba(239, 68, 68, 0.5)' :
|
||||||
|
'rgba(239, 68, 68, 0.8)'
|
||||||
|
),
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
...chartOptions,
|
||||||
|
plugins: {
|
||||||
|
...chartOptions.plugins,
|
||||||
|
legend: { display: false }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Comparison Tab
|
||||||
|
const renderComparison = () => {
|
||||||
|
if (!comparison) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">{comparison.period2.label}</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.income')}</span>
|
||||||
|
<span className="text-success">{currency(comparison.period2.income)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.expenses')}</span>
|
||||||
|
<span className="text-danger">{currency(comparison.period2.expense)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span className="text-slate-400">{t('reports.balance')}</span>
|
||||||
|
<span className={comparison.period2.balance >= 0 ? 'text-success' : 'text-danger'}>
|
||||||
|
{currency(comparison.period2.balance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a', border: '2px solid #3b82f6' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
{comparison.period1.label}
|
||||||
|
<span className="badge bg-primary ms-2">Actual</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.income')}</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-success">{currency(comparison.period1.income)}</span>
|
||||||
|
{comparison.variation.income !== 0 && (
|
||||||
|
<span className={`badge ms-2 ${comparison.variation.income >= 0 ? 'bg-success' : 'bg-danger'}`}>
|
||||||
|
{comparison.variation.income > 0 ? '+' : ''}{comparison.variation.income}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.expenses')}</span>
|
||||||
|
<div>
|
||||||
|
<span className="text-danger">{currency(comparison.period1.expense)}</span>
|
||||||
|
{comparison.variation.expense !== 0 && (
|
||||||
|
<span className={`badge ms-2 ${comparison.variation.expense <= 0 ? 'bg-success' : 'bg-danger'}`}>
|
||||||
|
{comparison.variation.expense > 0 ? '+' : ''}{comparison.variation.expense}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span className="text-slate-400">{t('reports.balance')}</span>
|
||||||
|
<span className={comparison.period1.balance >= 0 ? 'text-success' : 'text-danger'}>
|
||||||
|
{currency(comparison.period1.balance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comparison Chart */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body" style={{ height: '300px' }}>
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels: [t('reports.income'), t('reports.expenses'), t('reports.balance')],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: comparison.period2.label,
|
||||||
|
data: [comparison.period2.income, comparison.period2.expense, comparison.period2.balance],
|
||||||
|
backgroundColor: 'rgba(148, 163, 184, 0.5)',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: comparison.period1.label,
|
||||||
|
data: [comparison.period1.income, comparison.period1.expense, comparison.period1.balance],
|
||||||
|
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)', 'rgba(59, 130, 246, 0.7)'],
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
options={chartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Top Expenses Tab
|
||||||
|
const renderTopExpenses = () => {
|
||||||
|
if (!topExpenses) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent d-flex justify-content-between">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-sort-down me-2"></i>
|
||||||
|
Top 20 Gastos del Mes
|
||||||
|
</h6>
|
||||||
|
<span className="text-danger fw-bold">{currency(topExpenses.total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-dark table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th>Categoría</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th className="text-end">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{topExpenses.data.map((t, i) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td><span className="badge bg-secondary">{i + 1}</span></td>
|
||||||
|
<td className="text-truncate" style={{ maxWidth: '200px' }}>{t.description}</td>
|
||||||
|
<td><span className="badge bg-primary">{t.category || '-'}</span></td>
|
||||||
|
<td className="text-slate-400">{t.date}</td>
|
||||||
|
<td className="text-end text-danger fw-bold">{currency(t.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Projection Tab
|
||||||
|
const renderProjection = () => {
|
||||||
|
if (!projection) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-calendar3 me-2"></i>
|
||||||
|
Mes Actual
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.income')}</span>
|
||||||
|
<span className="text-success">{currency(projection.current_month.income)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="text-slate-400">{t('reports.expenses')}</span>
|
||||||
|
<span className="text-danger">{currency(projection.current_month.expense)}</span>
|
||||||
|
</div>
|
||||||
|
<hr className="border-secondary" />
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span className="text-slate-400">{t('reports.daysRemaining')}</span>
|
||||||
|
<span className="text-white">{projection.current_month.days_remaining} días</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card border-0" style={{ background: 'linear-gradient(135deg, #1e40af 0%, #3b82f6 100%)' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-lightning me-2"></i>
|
||||||
|
{t('reports.projectionTitle')}
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body text-white">
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="opacity-75">{t('reports.projectedIncome')}</span>
|
||||||
|
<span className="fw-bold">{currency(projection.projection.income)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex justify-content-between mb-3">
|
||||||
|
<span className="opacity-75">{t('reports.projectedExpense')}</span>
|
||||||
|
<span className="fw-bold">{currency(projection.projection.expense)}</span>
|
||||||
|
</div>
|
||||||
|
<hr className="border-white opacity-25" />
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<span className="opacity-75">{t('reports.balance')}</span>
|
||||||
|
<span className={`fw-bold ${projection.projection.balance >= 0 ? '' : 'text-warning'}`}>
|
||||||
|
{currency(projection.projection.balance)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* vs Average */}
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-bar-chart me-2"></i>
|
||||||
|
{t('reports.vsAverage')} (últimos 3 meses)
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '250px' }}>
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels: [t('reports.income'), t('reports.expenses')],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Promedio histórico',
|
||||||
|
data: [projection.historical_average.income, projection.historical_average.expense],
|
||||||
|
backgroundColor: 'rgba(148, 163, 184, 0.5)',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Proyección mes',
|
||||||
|
data: [projection.projection.income, projection.projection.expense],
|
||||||
|
backgroundColor: ['rgba(16, 185, 129, 0.7)', 'rgba(239, 68, 68, 0.7)'],
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
options={chartOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render Day of Week Tab
|
||||||
|
const renderDayOfWeek = () => {
|
||||||
|
if (!dayOfWeekData) return null;
|
||||||
|
|
||||||
|
const days = ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row g-4">
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-header border-0 bg-transparent">
|
||||||
|
<h6 className="text-white mb-0">
|
||||||
|
<i className="bi bi-calendar-week me-2"></i>
|
||||||
|
Gastos por Día de la Semana
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ height: '300px' }}>
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels: dayOfWeekData.map(d => days[d.day_num - 1]),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Total gastado',
|
||||||
|
data: dayOfWeekData.map(d => d.total),
|
||||||
|
backgroundColor: dayOfWeekData.map(d =>
|
||||||
|
d.day_num === 1 || d.day_num === 7
|
||||||
|
? 'rgba(245, 158, 11, 0.7)'
|
||||||
|
: 'rgba(59, 130, 246, 0.7)'
|
||||||
|
),
|
||||||
|
borderRadius: 4,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
...chartOptions,
|
||||||
|
plugins: { ...chartOptions.plugins, legend: { display: false } }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-12">
|
||||||
|
<div className="card border-0" style={{ background: '#1e293b' }}>
|
||||||
|
<div className="card-body p-0">
|
||||||
|
<table className="table table-dark mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Día</th>
|
||||||
|
<th className="text-center">Transacciones</th>
|
||||||
|
<th className="text-end">Total</th>
|
||||||
|
<th className="text-end">Promedio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dayOfWeekData.map(d => (
|
||||||
|
<tr key={d.day_num}>
|
||||||
|
<td>
|
||||||
|
<i className={`bi bi-calendar3 me-2 ${d.day_num === 1 || d.day_num === 7 ? 'text-warning' : 'text-primary'}`}></i>
|
||||||
|
{d.day}
|
||||||
|
</td>
|
||||||
|
<td className="text-center"><span className="badge bg-secondary">{d.count}</span></td>
|
||||||
|
<td className="text-end text-danger">{currency(d.total)}</td>
|
||||||
|
<td className="text-end text-slate-400">{currency(d.average)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border text-primary" role="status">
|
||||||
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'summary': return renderSummary();
|
||||||
|
case 'category': return renderCategory();
|
||||||
|
case 'evolution': return renderEvolution();
|
||||||
|
case 'comparison': return renderComparison();
|
||||||
|
case 'topExpenses': return renderTopExpenses();
|
||||||
|
case 'projection': return renderProjection();
|
||||||
|
case 'dayOfWeek': return renderDayOfWeek();
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reports-container">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white mb-1 fw-bold">
|
||||||
|
<i className="bi bi-bar-chart-line me-2"></i>
|
||||||
|
{t('reports.title')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-slate-400 mb-0 small">{t('reports.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="card border-0 mb-4" style={{ background: '#0f172a' }}>
|
||||||
|
<div className="card-body p-2">
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
className={`btn btn-sm ${activeTab === tab.id ? 'btn-primary' : 'btn-outline-secondary'}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
>
|
||||||
|
<i className={`bi ${tab.icon} me-1`}></i>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Reports;
|
||||||
@ -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;
|
export default api;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user