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/).
|
||||
|
||||
|
||||
## [1.32.0] - 2025-12-14
|
||||
|
||||
### Added
|
||||
- **Salud Financiera (Financial Health)** - Nueva página completa
|
||||
- Puntuación de 0-100 con anillo visual animado
|
||||
- 6 métricas de salud: Capacidad de Ahorro, Control de Deudas, Gestión de Presupuesto, Inversiones, Fondo de Emergencia, Planificación Futura
|
||||
- Tarjetas de métricas con gradientes de color
|
||||
- Gráfico de evolución histórica de score
|
||||
- Sección de insights con alertas y sugerencias
|
||||
- Recomendaciones personalizadas
|
||||
- Cards de estadísticas rápidas (balance, ingresos, gastos, tasa de ahorro)
|
||||
|
||||
- **Metas Financieras (Goals)** - Sistema completo de objetivos de ahorro
|
||||
- Crear, editar y eliminar metas con icono y color personalizable
|
||||
- Barra de progreso visual con porcentaje
|
||||
- Estados: Activo, Completado, Pausado, Cancelado
|
||||
- Fecha objetivo y cálculo de meses restantes
|
||||
- Contribuciones con modal dedicado
|
||||
- Indicador "On Track" basado en contribución mensual requerida
|
||||
- Estadísticas: total metas, activas, total ahorrado, restante
|
||||
- Acciones: contribuir, pausar, reanudar, marcar completada
|
||||
|
||||
- **Presupuestos Mensuales (Budgets)** - Control de gastos por categoría
|
||||
- Crear presupuestos por categoría y mes
|
||||
- Selector de mes/año con navegación
|
||||
- Barra de progreso con colores semáforo (verde/amarillo/rojo)
|
||||
- Alertas de "Excedido" y "Casi al límite"
|
||||
- Resumen mensual: presupuestado, gastado, restante, % uso
|
||||
- Copiar presupuestos al mes siguiente
|
||||
- Tabla de resumen anual con click para navegar
|
||||
|
||||
- **Reportes (Reports)** - Dashboard de análisis financiero
|
||||
- 7 pestañas: Resumen, Por Categoría, Evolución Mensual, Comparativa, Mayores Gastos, Proyección, Por Día
|
||||
- Gráficos: Barras, Líneas, Dona (usando Chart.js)
|
||||
- Resumen anual con comparativa vs año anterior
|
||||
- Distribución de gastos por categoría con tabla detallada
|
||||
- Evolución mensual de ingresos/gastos/balance
|
||||
- Tasa de ahorro mensual con colores semáforo
|
||||
- Top 20 gastos del mes
|
||||
- Proyección de fin de mes
|
||||
- Gastos por día de la semana
|
||||
|
||||
- **Backend API** - Nuevos endpoints
|
||||
- `GET/POST /api/financial-goals` - CRUD de metas
|
||||
- `POST /api/financial-goals/{id}/contributions` - Añadir contribución
|
||||
- `DELETE /api/financial-goals/{id}/contributions/{contributionId}` - Eliminar contribución
|
||||
- `GET/POST /api/budgets` - CRUD de presupuestos
|
||||
- `GET /api/budgets/available-categories` - Categorías sin presupuesto
|
||||
- `POST /api/budgets/copy-to-next-month` - Copiar al mes siguiente
|
||||
- `GET /api/budgets/year-summary` - Resumen anual
|
||||
- `GET /api/financial-health` - Score y métricas de salud financiera
|
||||
- `GET /api/financial-health/history` - Histórico de scores
|
||||
- `GET /api/reports/summary` - Resumen por período
|
||||
- `GET /api/reports/by-category` - Gastos por categoría
|
||||
- `GET /api/reports/monthly-evolution` - Evolución mensual
|
||||
- `GET /api/reports/by-day-of-week` - Gastos por día de semana
|
||||
- `GET /api/reports/top-expenses` - Mayores gastos
|
||||
- `GET /api/reports/compare-periods` - Comparativa de períodos
|
||||
- `GET /api/reports/projection` - Proyección del mes
|
||||
|
||||
- **Base de datos** - Nuevas tablas
|
||||
- `financial_goals` - Metas de ahorro con objetivo, fecha, estado
|
||||
- `goal_contributions` - Contribuciones a metas
|
||||
- `budgets` - Presupuestos mensuales por categoría
|
||||
|
||||
- **Navegación** - Nuevo grupo "Planificación" en sidebar
|
||||
- Salud Financiera
|
||||
- Metas
|
||||
- Presupuestos
|
||||
- Reportes
|
||||
|
||||
### Changed
|
||||
- **Layout.jsx** - Nuevo grupo de navegación "planning" con 4 items
|
||||
- **App.jsx** - 4 nuevas rutas: /financial-health, /goals, /budgets, /reports
|
||||
- **api.js** - Nuevos services: financialGoalService, budgetService, reportService, financialHealthService
|
||||
|
||||
|
||||
## [1.31.2] - 2025-12-14
|
||||
|
||||
### Added
|
||||
|
||||
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\ServiceSheetController;
|
||||
use App\Http\Controllers\Api\PromotionalCampaignController;
|
||||
use App\Http\Controllers\Api\FinancialGoalController;
|
||||
use App\Http\Controllers\Api\BudgetController;
|
||||
use App\Http\Controllers\Api\ReportController;
|
||||
use App\Http\Controllers\Api\FinancialHealthController;
|
||||
|
||||
// Public routes with rate limiting
|
||||
Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:register');
|
||||
@ -232,5 +236,41 @@
|
||||
Route::post('campaigns/{id}/products', [PromotionalCampaignController::class, 'addProducts']);
|
||||
Route::delete('campaigns/{id}/products', [PromotionalCampaignController::class, 'removeProducts']);
|
||||
Route::put('campaigns/{campaignId}/products/{productId}', [PromotionalCampaignController::class, 'updateProductDiscount']);
|
||||
|
||||
// ============================================
|
||||
// Metas Financieras (Financial Goals)
|
||||
// ============================================
|
||||
Route::apiResource('financial-goals', FinancialGoalController::class);
|
||||
Route::post('financial-goals/{id}/contributions', [FinancialGoalController::class, 'addContribution']);
|
||||
Route::delete('financial-goals/{goalId}/contributions/{contributionId}', [FinancialGoalController::class, 'removeContribution']);
|
||||
|
||||
// ============================================
|
||||
// Presupuestos (Budgets)
|
||||
// ============================================
|
||||
Route::get('budgets/available-categories', [BudgetController::class, 'availableCategories']);
|
||||
Route::get('budgets/year-summary', [BudgetController::class, 'yearSummary']);
|
||||
Route::post('budgets/copy-to-next-month', [BudgetController::class, 'copyToNextMonth']);
|
||||
Route::apiResource('budgets', BudgetController::class);
|
||||
|
||||
// ============================================
|
||||
// Reportes (Reports)
|
||||
// ============================================
|
||||
Route::prefix('reports')->group(function () {
|
||||
Route::get('summary', [ReportController::class, 'summary']);
|
||||
Route::get('by-category', [ReportController::class, 'byCategory']);
|
||||
Route::get('monthly-evolution', [ReportController::class, 'monthlyEvolution']);
|
||||
Route::get('by-day-of-week', [ReportController::class, 'byDayOfWeek']);
|
||||
Route::get('top-expenses', [ReportController::class, 'topExpenses']);
|
||||
Route::get('compare-periods', [ReportController::class, 'comparePeriods']);
|
||||
Route::get('accounts', [ReportController::class, 'accountsReport']);
|
||||
Route::get('projection', [ReportController::class, 'projection']);
|
||||
Route::get('recurring', [ReportController::class, 'recurringReport']);
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Salud Financiera (Financial Health)
|
||||
// ============================================
|
||||
Route::get('financial-health', [FinancialHealthController::class, 'index']);
|
||||
Route::get('financial-health/history', [FinancialHealthController::class, 'history']);
|
||||
});
|
||||
|
||||
|
||||
@ -17,6 +17,10 @@ import TransferDetection from './pages/TransferDetection';
|
||||
import RefundDetection from './pages/RefundDetection';
|
||||
import RecurringTransactions from './pages/RecurringTransactions';
|
||||
import Business from './pages/Business';
|
||||
import FinancialHealth from './pages/FinancialHealth';
|
||||
import Goals from './pages/Goals';
|
||||
import Budgets from './pages/Budgets';
|
||||
import Reports from './pages/Reports';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@ -135,6 +139,46 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/financial-health"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<FinancialHealth />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/goals"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Goals />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/budgets"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Budgets />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/reports"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Reports />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
</Routes>
|
||||
<CookieConsent />
|
||||
|
||||
@ -30,6 +30,7 @@ const Layout = ({ children }) => {
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState({
|
||||
movements: true,
|
||||
planning: true,
|
||||
settings: false,
|
||||
});
|
||||
|
||||
@ -64,6 +65,18 @@ const Layout = ({ children }) => {
|
||||
},
|
||||
{ type: 'item', path: '/liabilities', icon: 'bi-bank', label: t('nav.liabilities') },
|
||||
{ type: 'item', path: '/business', icon: 'bi-briefcase', label: t('nav.business') },
|
||||
{
|
||||
type: 'group',
|
||||
id: 'planning',
|
||||
icon: 'bi-graph-up',
|
||||
label: t('nav.planning'),
|
||||
items: [
|
||||
{ path: '/financial-health', icon: 'bi-heart-pulse', label: t('nav.financialHealth') },
|
||||
{ path: '/goals', icon: 'bi-flag', label: t('nav.goals') },
|
||||
{ path: '/budgets', icon: 'bi-wallet2', label: t('nav.budgets') },
|
||||
{ path: '/reports', icon: 'bi-bar-chart-line', label: t('nav.reports') },
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
id: 'settings',
|
||||
|
||||
@ -90,7 +90,11 @@
|
||||
"settings": "Configuración",
|
||||
"business": "Negocio",
|
||||
"profile": "Perfil",
|
||||
"help": "Ayuda"
|
||||
"help": "Ayuda",
|
||||
"planning": "Planificación",
|
||||
"financialHealth": "Salud Financiera",
|
||||
"goals": "Metas",
|
||||
"budgets": "Presupuestos"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de Control",
|
||||
@ -1477,5 +1481,178 @@
|
||||
"status": "Estado",
|
||||
"totalCmv": "CMV Total"
|
||||
}
|
||||
},
|
||||
"financialHealth": {
|
||||
"title": "Salud Financiera",
|
||||
"subtitle": "Evaluación integral de tus finanzas",
|
||||
"lastUpdate": "Última actualización",
|
||||
"overallScore": "Tu puntuación general",
|
||||
"excellent": "Excelente Salud",
|
||||
"good": "Buena Salud",
|
||||
"moderate": "Salud Moderada",
|
||||
"needsWork": "Necesita Mejorar",
|
||||
"critical": "Atención Necesaria",
|
||||
"onTrack": "Estás en buen camino, pero hay espacio para mejorar",
|
||||
"metrics": {
|
||||
"savingsCapacity": "Capacidad de ahorro",
|
||||
"debtControl": "Control de deudas",
|
||||
"budgetManagement": "Gestión de presupuesto",
|
||||
"investments": "Inversiones",
|
||||
"emergencyFund": "Fondo de emergencia",
|
||||
"futurePlanning": "Planificación futuro"
|
||||
},
|
||||
"insights": {
|
||||
"highPriority": "Prioridad Alta",
|
||||
"mediumPriority": "Prioridad Media",
|
||||
"achievement": "Logro Destacado",
|
||||
"opportunity": "Oportunidad",
|
||||
"upcomingGoal": "Meta Próxima",
|
||||
"suggestion": "Sugerencia"
|
||||
}
|
||||
},
|
||||
"goals": {
|
||||
"title": "Metas Financieras",
|
||||
"subtitle": "Alcanza tus objetivos de ahorro",
|
||||
"newGoal": "Nueva Meta",
|
||||
"editGoal": "Editar Meta",
|
||||
"addGoal": "Nueva Meta",
|
||||
"deleteGoal": "Eliminar Meta",
|
||||
"deleteConfirm": "¿Estás seguro de eliminar la meta \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"noGoals": "No tienes metas configuradas",
|
||||
"noGoalsDescription": "Crea metas financieras para organizar tus ahorros y alcanzar tus objetivos.",
|
||||
"createFirst": "Crea tu primera meta para empezar a ahorrar",
|
||||
"createFirstGoal": "Crear Primera Meta",
|
||||
"name": "Nombre de la meta",
|
||||
"description": "Descripción",
|
||||
"targetAmount": "Monto objetivo",
|
||||
"currentAmount": "Monto actual",
|
||||
"targetDate": "Fecha objetivo",
|
||||
"monthlyContribution": "Ahorro mensual",
|
||||
"priority": "Prioridad",
|
||||
"progress": "Progreso",
|
||||
"remaining": "Faltan",
|
||||
"completed": "completado",
|
||||
"monthsRemaining": "Tiempo restante",
|
||||
"months": "meses",
|
||||
"contribute": "Contribuir",
|
||||
"addContribution": "Añadir contribución",
|
||||
"contributeAmount": "Monto a contribuir",
|
||||
"contributionDate": "Fecha",
|
||||
"notes": "Notas",
|
||||
"notesPlaceholder": "Nota opcional sobre esta contribución",
|
||||
"icon": "Icono",
|
||||
"color": "Color",
|
||||
"onTrack": "¡Vas por buen camino!",
|
||||
"needsMore": "Necesitas ahorrar {{amount}}/mes para cumplir tu meta",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"markCompleted": "Marcar como completada",
|
||||
"totalGoals": "Total Metas",
|
||||
"activeGoals": "Metas Activas",
|
||||
"totalSaved": "Total Ahorrado",
|
||||
"statusActive": "En progreso",
|
||||
"statusCompleted": "Completada",
|
||||
"statusPaused": "Pausada",
|
||||
"statusCancelled": "Cancelada",
|
||||
"status": {
|
||||
"active": "En progreso",
|
||||
"completed": "Completada",
|
||||
"paused": "Pausada",
|
||||
"cancelled": "Cancelada",
|
||||
"advancing": "Avanzando",
|
||||
"starting": "Inicio"
|
||||
},
|
||||
"stats": {
|
||||
"totalGoals": "Total metas",
|
||||
"activeGoals": "Metas activas",
|
||||
"completedGoals": "Completadas",
|
||||
"totalTarget": "Objetivo total",
|
||||
"totalSaved": "Total ahorrado",
|
||||
"overallProgress": "Progreso general"
|
||||
},
|
||||
"congratulations": "¡Felicitaciones!",
|
||||
"goalCompleted": "Meta completada el",
|
||||
"viewDetails": "Ver detalles",
|
||||
"archive": "Archivar"
|
||||
},
|
||||
"budgets": {
|
||||
"title": "Presupuestos Mensuales",
|
||||
"subtitle": "Controla tus gastos por categoría",
|
||||
"newBudget": "Nuevo Presupuesto",
|
||||
"editBudget": "Editar Presupuesto",
|
||||
"addBudget": "Nuevo Presupuesto",
|
||||
"deleteBudget": "Eliminar Presupuesto",
|
||||
"deleteConfirm": "¿Estás seguro de eliminar el presupuesto de \"{{category}}\"?",
|
||||
"noBudgets": "No tienes presupuestos configurados",
|
||||
"noBudgetsDescription": "Crea presupuestos mensuales para controlar y limitar tus gastos por categoría.",
|
||||
"createFirst": "Crear Primer Presupuesto",
|
||||
"category": "Categoría",
|
||||
"selectCategory": "Seleccionar categoría",
|
||||
"amount": "Monto",
|
||||
"spent": "Gastado",
|
||||
"budgeted": "Presupuestado",
|
||||
"remaining": "Restante",
|
||||
"exceeded": "Excedido por",
|
||||
"usage": "Uso",
|
||||
"copyToNext": "Copiar al siguiente",
|
||||
"month": "Mes",
|
||||
"yearSummary": "Resumen Anual",
|
||||
"totalBudgeted": "Total Presupuestado",
|
||||
"totalSpent": "Total Gastado",
|
||||
"almostExceeded": "Cerca del límite (80%+)",
|
||||
"summary": {
|
||||
"totalBudget": "Presupuesto Total",
|
||||
"totalSpent": "Gastado",
|
||||
"available": "Disponible",
|
||||
"usagePercent": "% Utilizado"
|
||||
},
|
||||
"alert": {
|
||||
"onTrack": "Bajo control",
|
||||
"warning": "Cerca del límite",
|
||||
"exceeded": "¡Excedido!"
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reportes",
|
||||
"subtitle": "Análisis detallado de tus finanzas",
|
||||
"summary": "Resumen",
|
||||
"byCategory": "Por Categoría",
|
||||
"monthlyEvolution": "Evolución Mensual",
|
||||
"comparison": "Comparativa",
|
||||
"topExpenses": "Mayores Gastos",
|
||||
"projection": "Proyección",
|
||||
"recurring": "Recurrentes",
|
||||
"accounts": "Por Cuenta",
|
||||
"period": "Período",
|
||||
"selectPeriod": "Seleccionar período",
|
||||
"thisMonth": "Este mes",
|
||||
"lastMonth": "Mes anterior",
|
||||
"last3Months": "Últimos 3 meses",
|
||||
"last6Months": "Últimos 6 meses",
|
||||
"thisYear": "Este año",
|
||||
"lastYear": "Año anterior",
|
||||
"custom": "Personalizado",
|
||||
"income": "Ingresos",
|
||||
"expenses": "Gastos",
|
||||
"balance": "Balance",
|
||||
"savingsRate": "Tasa de ahorro",
|
||||
"avgIncome": "Ingreso promedio",
|
||||
"avgExpense": "Gasto promedio",
|
||||
"vsLastPeriod": "vs período anterior",
|
||||
"dayOfWeek": {
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles",
|
||||
"thursday": "Jueves",
|
||||
"friday": "Viernes",
|
||||
"saturday": "Sábado"
|
||||
},
|
||||
"projectionTitle": "Proyección del mes",
|
||||
"projectedExpense": "Gasto proyectado",
|
||||
"projectedIncome": "Ingreso proyectado",
|
||||
"daysRemaining": "Días restantes",
|
||||
"vsAverage": "vs promedio histórico"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user