73 Gráficos Avanzados con Chart.js en Laravel 12: Comparativa Capital vs. Interés Mensual 📊💸
Duración: 9 minDescripción
📊 Lección 73: Gráficos Avanzados y Alertas de Mora en Laravel 12
En esta sesión de Benji V2, fortalecemos el Dashboard con métricas críticas para la salud financiera del negocio. Implementamos un sistema de detección de atrasos y una comparativa histórica que permite al administrador visualizar la rentabilidad del sistema mes a mes.
🚨 Reporte de Clientes con Atrasos (Mora)
Automatizamos la identificación de cobros pendientes para mejorar la liquidez:
- 🔍 Consultas de Alerta: Creamos tres métricas clave para el Dashboard:
- Clientes con cuotas vencidas: Contabiliza usuarios únicos que tienen al menos un pago pendiente cuya fecha de vencimiento es menor a la actual [02:12].
- Total de cuotas vencidas: Suma la cantidad de recibos que han superado el plazo de pago [02:42].
- Monto vencido total: Calcula el valor monetario exacto que no ha ingresado al sistema, alertando sobre el riesgo financiero [03:07].
- 📲 Acceso Directo: Añadimos un botón de acción rápida que redirige al administrador directamente al módulo de notificaciones para gestionar el cobro con los clientes afectados [04:27].
💸 Comparativa: Capital vs. Interés Mensual
Visualizamos el rendimiento real del sistema mediante un gráfico de barras avanzado:
- 📈 Análisis Histórico: Implementamos una lógica de consulta que recorre los últimos 11 meses, calculando para cada periodo cuánto dinero corresponde al capital recuperado y cuánto a los intereses generados [05:54].
- 📊 Integración con Chart.js: Diseñamos un gráfico de barras dual (type: 'bar') que permite ver de forma comparativa el flujo de caja. Esta visualización es fundamental para entender el retorno de inversión (ROI) del sistema [07:07].
✅ Resultado de la Lección
Al finalizar, el administrador tiene una visión 360° del negocio:
- Sabe exactamente cuánto dinero hay en mora y quién lo debe.
- Puede analizar si los intereses generados justifican el capital invertido a lo largo del tiempo.
- El Dashboard deja de ser solo decorativo para convertirse en una herramienta de toma de decisiones financieras.
Código fuente del archivo AdminController.php
class AdminController extends Controller
{
public function index()
{
$ajuste = Ajuste::first();
$total_roles = Role::count();
$rolesNuevosMes = Role::whereMonth('created_at', now()->month)->count();
$totalUsuarios = User::count();
$usuariosNuevosMes = User::whereMonth('created_at', now()->month)->count();
$totalClientes = Cliente::count();
$clientesNuevosMes = Cliente::whereMonth('created_at', now()->month)->count();
$totalCategorias = Categoria::count();
$categoriasNuevasMes = Categoria::whereMonth('created_at', now()->month)->count();
$montoPrestadoTotal = Prestamo::sum('monto_prestado');
$capitalRecuperadoTotal = Pago::whereNotNull('fecha_cancelado')->sum('monto_capital');
$saldoPendienteTotal = Pago::where('estado', 'pendiente')
->selectRaw('COALESCE(SUM(CASE WHEN monto_cuota > monto_total_pagado THEN monto_cuota - monto_total_pagado ELSE 0 END), 0) as total')
->value('total');
$carteraActivaTotal = $saldoPendienteTotal;
$totalPrestamos = Prestamo::count();
$prestamosNuevosMes = Prestamo::whereMonth('created_at', now()->month)->count();
$totalPrestamosActivos = Prestamo::where('estado', 'pendiente')->count();
$prestamosActivosMes = Prestamo::where('estado', 'pendiente')
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
$clientesConCuotasVencidas = Cliente::whereHas('prestamos.pagos', function ($query) {
$query->where('estado', 'pendiente')
->whereDate('fecha_vencimiento', '<', now()->toDateString());
})->count();
$cuotasVencidasTotal = Pago::where('estado', 'pendiente')
->whereDate('fecha_vencimiento', '<', now()->toDateString())
->count();
$montoVencidoTotal = Pago::where('estado', 'pendiente')
->whereDate('fecha_vencimiento', '<', now()->toDateString())
->selectRaw('COALESCE(SUM(CASE WHEN monto_cuota > monto_total_pagado THEN monto_cuota - monto_total_pagado ELSE 0 END), 0) as total')
->value('total');
$inicioRango = now()->copy()->subMonths(11)->startOfMonth();
$capitalInteresPorMes = Pago::query()
->whereNotNull('fecha_cancelado')
->whereDate('fecha_cancelado', '>=', $inicioRango)
->selectRaw('YEAR(fecha_cancelado) as anio, MONTH(fecha_cancelado) as mes, COALESCE(SUM(monto_capital),0) as total_capital, COALESCE(SUM(monto_interes),0) as total_interes')
->groupBy('anio', 'mes')
->orderBy('anio')
->orderBy('mes')
->get()
->keyBy(fn($item) => $item->anio . '-' . str_pad((string) $item->mes, 2, '0', STR_PAD_LEFT));
$labelsCapitalInteres = [];
$datosCapitalMes = [];
$datosInteresMes = [];
for ($i = 11; $i >= 0; $i--) {
$fecha = now()->copy()->subMonths($i);
$clave = $fecha->format('Y-m');
$labelsCapitalInteres[] = Carbon::createFromDate($fecha->year, $fecha->month, 1)->translatedFormat('M Y');
$datosCapitalMes[] = (float) ($capitalInteresPorMes[$clave]->total_capital ?? 0);
$datosInteresMes[] = (float) ($capitalInteresPorMes[$clave]->total_interes ?? 0);
}
return view('admin.index', compact(
'ajuste',
'totalClientes',
'clientesNuevosMes',
'total_roles',
'rolesNuevosMes',
'totalUsuarios',
'usuariosNuevosMes',
'montoPrestadoTotal',
'capitalRecuperadoTotal',
'saldoPendienteTotal',
'carteraActivaTotal',
'totalCategorias',
'categoriasNuevasMes',
'totalPrestamos',
'prestamosNuevosMes',
'totalPrestamosActivos',
'prestamosActivosMes',
'clientesConCuotasVencidas',
'cuotasVencidasTotal',
'montoVencidoTotal',
'labelsCapitalInteres',
'datosCapitalMes',
'datosInteresMes'
));
}
Contenido
Código fuente del archivo index.blade.php
<x-layouts.app :title="'Sistema de Prestamos y Cobranzas - Admin'">
<div class="mb-6 flex items-start justify-between gap-4">
<div>
<flux:heading size="xl" level="1">Bienvenido al Sistema</flux:heading>
<flux:text class="mt-2 text-gray-600 dark:text-gray-400">
Resumen general del sistema de préstamos y cobranzas
</flux:text>
</div>
<div class="text-right">
<flux:text class="text-blue-600 dark:text-white text-sm font-medium">
<i class="fas fa-user-shield mr-1"></i>
Rol del usuario
</flux:text>
<b class="text-blue-600 dark:text-white ">
<i class="fas fa-id-badge mr-1"></i>
{{ Auth::user()->roles->pluck('name')->implode(', ') }}
</b>
</div>
</div>
<flux:separator variant="subtle" />
<br>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
@can('Ver listado de roles')
<!-- Total roles -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Total Roles</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $total_roles ?? 0 }}</flux:heading>
<flux:text class="text-green-600 dark:text-green-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $rolesNuevosMes ?? 0 }} nuevos este mes
</flux:text>
</div>
<div class="p-3 dark:bg-green-900/30 rounded-lg">
<i class="fas fa-shield-alt text-green-600 dark:text-green-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartRoles" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de usuarios')
<!-- Total Usuarios -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Total Usuarios</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $totalUsuarios ?? 0 }}</flux:heading>
<flux:text class="text-violet-600 dark:text-violet-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $usuariosNuevosMes ?? 0 }} nuevos este mes
</flux:text>
</div>
<div class="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-lg">
<i class="fas fa-users text-violet-600 dark:text-violet-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartUsuarios" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de clientes')
<!-- Total Clientes -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Total Clientes</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $totalClientes ?? 0 }}</flux:heading>
<flux:text class="text-blue-600 dark:text-blue-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $clientesNuevosMes ?? 0 }} nuevos este mes
</flux:text>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<i class="fas fa-users text-blue-600 dark:text-blue-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartClientes" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de categorias')
<!-- Total Categorías -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Total Categorías</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $totalCategorias ?? 0 }}</flux:heading>
<flux:text class="text-amber-600 dark:text-amber-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $categoriasNuevasMes ?? 0 }} nuevas este mes
</flux:text>
</div>
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<i class="fas fa-tags text-amber-600 dark:text-amber-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartCategorias" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de prestamos')
<!-- Total Préstamos -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Total Préstamos</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $totalPrestamos ?? 0 }}</flux:heading>
<flux:text class="text-rose-600 dark:text-rose-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $prestamosNuevosMes ?? 0 }} nuevos este mes
</flux:text>
</div>
<div class="p-3 bg-rose-100 dark:bg-rose-900/30 rounded-lg">
<i class="fas fa-file-invoice-dollar text-rose-600 dark:text-rose-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartPrestamos" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de prestamos')
<!-- Préstamos Activos -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Préstamos Activos
</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $totalPrestamosActivos ?? 0 }}</flux:heading>
<flux:text class="text-cyan-600 dark:text-cyan-400 text-xs mt-2">
<i class="fas fa-arrow-up mr-1"></i>{{ $prestamosActivosMes ?? 0 }} activos este mes
</flux:text>
</div>
<div class="p-3 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<i class="fas fa-hand-holding-dollar text-cyan-600 dark:text-cyan-400 text-2xl"></i>
</div>
</div>
<div class="h-12" style="margin-top:-25px">
<canvas id="chartPrestamosActivos" class="w-full block" height="48"></canvas>
</div>
</div>
@endcan
@can('Ver listado de notificaciones')
<!-- Notificaciones de atrasos -->
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3
shadow-md hover:shadow-lg transition">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Clientes con atrasos
</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $clientesConCuotasVencidas ?? 0 }}</flux:heading>
<flux:text class="text-red-600 dark:text-red-400 text-xs mt-2">
Cuotas vencidas: {{ $cuotasVencidasTotal ?? 0 }}
</flux:text>
<flux:text class="text-gray-600 dark:text-gray-400 text-xs mt-1">
Monto vencido: {{ $ajuste->divisa }} {{ number_format($montoVencidoTotal ?? 0, 2) }}
</flux:text>
</div>
<div class="p-3 bg-red-100 dark:bg-red-900/30 rounded-lg">
<i class="fas fa-bell text-red-600 dark:text-red-400 text-2xl"></i>
</div>
</div>
<a href="{{ route('admin.notificaciones.index') }}"
class="inline-flex items-center px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-semibold rounded transition">
<i class="fas fa-eye mr-1.5"></i> Ver listado de notificaciones
</a>
</div>
@endcan
</div>
<style>
.resumen-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
width: 100%;
}
.resumen-grid>div {
min-width: 0;
overflow: hidden;
}
@media (min-width: 1024px) {
.resumen-grid {
grid-template-columns: repeat(12, minmax(0, 1fr));
}
.resumen-cartera {
grid-column: span 3 / span 3;
}
.resumen-capital-interes {
grid-column: span 9 / span 9;
}
}
</style>
@can('Ver listado de prestamos')
<div class="resumen-grid mb-8">
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200
dark:border-gray-700 p-3 shadow-md hover:shadow-lg transition resumen-cartera">
<div class="flex items-center justify-between mb-4">
<div>
<flux:text class="text-gray-600 dark:text-gray-400 text-sm font-medium">Cartera Activa</flux:text>
<flux:heading size="lg" level="3" class="mt-2 text-gray-900 dark:text-white">
{{ $ajuste->divisa }} {{ number_format($carteraActivaTotal ?? 0, 2) }}</flux:heading>
<flux:text class="text-emerald-600 dark:text-emerald-400 text-xs mt-2">
Total Prestado: {{ $ajuste->divisa }} {{ number_format($montoPrestadoTotal ?? 0, 2) }} <br>
Total Recuperado: {{ $ajuste->divisa }} {{ number_format($capitalRecuperadoTotal ?? 0, 2) }}
</flux:text>
<flux:text class="text-amber-600 dark:text-amber-400 text-xs mt-1">
Saldo pendiente: {{ $ajuste->divisa }} {{ number_format($saldoPendienteTotal ?? 0, 2) }}
</flux:text>
</div>
<div class="p-3 bg-emerald-100 dark:bg-emerald-900/30 rounded-lg">
<i class="fas fa-wallet text-emerald-600 dark:text-emerald-400 text-2xl"></i>
</div>
</div>
<div style="height: 420px; max-width: 360px; width: 100%; margin: 0 auto;">
<canvas id="chartCartera" class="w-full h-full"></canvas>
</div>
</div>
<div
class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 shadow-md hover:shadow-lg transition resumen-capital-interes">
<div class="flex items-center justify-between mb-4">
<flux:heading size="lg" level="3" class="text-gray-900 dark:text-white">Capital vs Interés
por mes
</flux:heading>
</div>
<div style="height: 420px; width: 100%;">
<canvas id="chartCapitalInteresMes" class="w-full h-full"></canvas>
</div>
</div>
</div>
@endcan