73 Gráficos Avanzados con Chart.js en Laravel 12: Comparativa Capital vs. Interés Mensual 📊💸

Duración: 9 min
Módulo: Módulo Reportes Lección 2 de 2

¡Contenido Exclusivo!

Adquiere este curso para tener acceso inmediato a esta y a **todas las lecciones Premium**.

Inscribirse Ahora por $10.00 Acceso instantáneo de por vida y código fuente incluido.

Descripció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:
    1. 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].
    2. Total de cuotas vencidas: Suma la cantidad de recibos que han superado el plazo de pago [02:42].
    3. 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