De
g.cze
Dashboard gerencial
Perfecto. Vamos a empezar por el Dashboard Gerencial. Es la puerta de entrada a toda la información macro de la clínica.
📊 Dashboard Gerencial - Diseño y Estructura
🎯 Objetivo del Dashboard
Proveer al gerente/administrador una vista panorámica de la salud del negocio en tiempo real, permitiendo identificar tendencias, detectar problemas y tomar decisiones informadas.
🧩 Estructura de la Pantalla
text
┌─────────────────────────────────────────────────────────────────────────┐ │ 📊 DASHBOARD GERENCIAL [Fecha: Ago 2026] │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ 💰 Facturación │ │ 🏦 Deuda │ │ 👥 Pacientes │ │ ✅ Conversión │ │ │ │ $125.430 │ │ $45.200 │ │ 142 activos │ │ 78% │ │ │ │ ↑ 12% │ │ ↓ 5% │ │ ↑ 15% │ │ ↑ 3% │ │ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ 📈 Evolución de Facturación (últimos 6 meses) │ │ │ │ ████████████████████████████████████████████████████████████ │ │ │ │ Ene Feb Mar Abr May Jun Jul Ago │ │ │ └──────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────┐ ┌─────────────────────────────────────┐ │ │ │ 🏆 Tratamientos más │ │ 🏥 Productividad por odontólogo │ │ │ │ frecuentes │ │ Dr. García ████████████░ 87% │ │ │ │ 1. Extracción 32% │ │ Dra. López ██████░░░░░░ 65% │ │ │ │ 2. Endodoncia 25% │ │ Dr. Pérez █████████░░░ 78% │ │ │ │ 3. Prótesis 18% │ └─────────────────────────────────────┘ │ │ │ 4. Implantes 15% │ │ │ │ 5. Ortodoncia 10% │ │ │ └─────────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ │ │ 📋 Últimos movimientos importantes │ │ │ │ ● Propuesta N°42 aceptada (Dr. García) - $2.300 │ │ │ │ ● Pago registrado (Juan Pérez) - $1.500 │ │ │ │ ● Nueva propuesta emitida (Dra. López) - $4.200 │ │ │ └──────────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘
📁 Estructura de Archivos
text
app/ ├── Http/ │ └── Controllers/ │ └── DashboardController.php ├── Services/ │ └── DashboardService.php resources/ ├── js/ │ ├── Pages/ │ │ └── Dashboard/ │ │ └── Gerencial.tsx │ └── Components/ │ └── Dashboard/ │ ├── KPICard.tsx │ ├── FacturacionChart.tsx │ ├── TratamientosFrecuentes.tsx │ ├── ProductividadOdontologos.tsx │ └── UltimosMovimientos.tsx routes/ └── web.php config/ └── dashboard.php
📊 Migración: Tabla de Métricas Mensuales (Cache)
php
// database/migrations/xxxx_xx_xx_create_metricas_mensuales_table.php <?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('metricas_mensuales', function (Blueprint $table) { $table->id(); $table->year('anio'); $table->unsignedTinyInteger('mes'); $table->decimal('facturacion_total', 12, 2)->default(0); $table->decimal('cobrado_total', 12, 2)->default(0); $table->decimal('deuda_total', 12, 2)->default(0); $table->unsignedInteger('propuestas_emitidas')->default(0); $table->unsignedInteger('propuestas_aceptadas')->default(0); $table->unsignedInteger('propuestas_rechazadas')->default(0); $table->unsignedInteger('nuevos_pacientes')->default(0); $table->unsignedInteger('pacientes_activos')->default(0); $table->unsignedInteger('tratamientos_realizados')->default(0); $table->unsignedInteger('pagos_registrados')->default(0); $table->json('tratamientos_detalle')->nullable(); // JSON con conteo por prestación $table->timestamps(); $table->unique(['anio', 'mes']); $table->index('anio'); $table->index('mes'); }); } public function down(): void { Schema::dropIfExists('metricas_mensuales'); } };
🧠 Servicio: DashboardService
php
// app/Services/DashboardService.php <?php namespace App\Services; use App\Models\Cuenta; use App\Models\Paciente; use App\Models\TratamientoPropuesta; use App\Models\Pago; use App\Models\MetricaMensual; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class DashboardService { /** * Obtener los KPIs principales. */ public function getKPIs(?int $consultorioId = null): array { // Facturación total (propuestas aceptadas) $facturacionTotal = TratamientoPropuesta::where('estado', 'ACEPTADA') ->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->sum('total'); // Deuda total $deudaTotal = Cuenta::when($consultorioId, function ($q) use ($consultorioId) { return $q->whereHas('paciente', fn($q2) => $q2->where('consultorio_id', $consultorioId)); })->sum('saldo'); // Pacientes activos (con propuestas en el último mes) $pacientesActivos = Paciente::whereHas('propuestas', function ($q) { $q->where('emitida_at', '>=', now()->subMonth()); })->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->count(); // Tasa de conversión $emitidas = TratamientoPropuesta::where('estado', '!=', 'BORRADOR') ->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->count(); $aceptadas = TratamientoPropuesta::where('estado', 'ACEPTADA') ->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->count(); $tasaConversion = $emitidas > 0 ? round(($aceptadas / $emitidas) * 100, 1) : 0; return [ 'facturacion_total' => $facturacionTotal, 'facturacion_vs_mes_anterior' => $this->calcularVariacionFacturacion($consultorioId), 'deuda_total' => $deudaTotal, 'deuda_vs_mes_anterior' => $this->calcularVariacionDeuda($consultorioId), 'pacientes_activos' => $pacientesActivos, 'pacientes_vs_mes_anterior' => $this->calcularVariacionPacientes($consultorioId), 'tasa_conversion' => $tasaConversion, 'tasa_conversion_vs_mes_anterior' => $this->calcularVariacionConversion($consultorioId), 'periodo' => now()->format('F Y'), ]; } /** * Obtener evolución de facturación (últimos N meses). */ public function getEvolucionFacturacion(int $meses = 6, ?int $consultorioId = null): Collection { $fechas = collect(); for ($i = $meses - 1; $i >= 0; $i--) { $mes = now()->subMonths($i); $fechas->push([ 'anio' => $mes->year, 'mes' => $mes->month, 'label' => $mes->format('M Y'), ]); } return $fechas->map(function ($fecha) use ($consultorioId) { $total = TratamientoPropuesta::where('estado', 'ACEPTADA') ->whereYear('emitida_at', $fecha['anio']) ->whereMonth('emitida_at', $fecha['mes']) ->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->sum('total'); return [ 'label' => $fecha['label'], 'facturacion' => $total, ]; }); } /** * Obtener tratamientos más frecuentes. */ public function getTratamientosFrecuentes(int $limit = 5, ?int $consultorioId = null): Collection { return DB::table('tratamiento_propuesta_items as tpi') ->join('prestaciones as p', 'tpi.prestacion_id', '=', 'p.id') ->join('tratamiento_propuestas as tp', 'tpi.tratamiento_propuesta_id', '=', 'tp.id') ->where('tp.estado', 'ACEPTADA') ->when($consultorioId, fn($q) => $q->where('tp.consultorio_id', $consultorioId)) ->select( 'p.id as prestacion_id', 'p.nombre as tratamiento', DB::raw('COUNT(*) as cantidad'), DB::raw('SUM(tpi.importe) as total_ingresado'), DB::raw('ROUND((COUNT(*) * 100.0 / (SELECT COUNT(*) FROM tratamiento_propuesta_items WHERE tratamiento_propuesta_id IN (SELECT id FROM tratamiento_propuestas WHERE estado = "ACEPTADA" ' . ($consultorioId ? 'AND consultorio_id = ' . $consultorioId : '') . '))), 1) as porcentaje') ) ->groupBy('p.id', 'p.nombre') ->orderBy('cantidad', 'desc') ->limit($limit) ->get(); } /** * Obtener productividad por odontólogo. */ public function getProductividadOdontologos(?int $consultorioId = null): Collection { return DB::table('tratamiento_propuestas as tp') ->join('users as u', 'tp.creado_por', '=', 'u.id') ->where('tp.estado', 'ACEPTADA') ->when($consultorioId, fn($q) => $q->where('tp.consultorio_id', $consultorioId)) ->select( 'u.id as odontologo_id', 'u.name as odontologo', DB::raw('COUNT(*) as propuestas_aceptadas'), DB::raw('SUM(tp.total) as total_facturado') ) ->groupBy('u.id', 'u.name') ->orderBy('total_facturado', 'desc') ->get() ->map(function ($item) { $maxTotal = $this->getMaxFacturadoOdontologo(); $item->porcentaje = $maxTotal > 0 ? round(($item->total_facturado / $maxTotal) * 100) : 0; return $item; }); } /** * Obtener últimos movimientos relevantes. */ public function getUltimosMovimientos(int $limit = 10, ?int $consultorioId = null): Collection { // Combinar eventos de propuestas y pagos $propuestas = TratamientoPropuesta::with('paciente', 'creador') ->whereIn('estado', ['ACEPTADA', 'EMITIDA', 'RECHAZADA']) ->when($consultorioId, fn($q) => $q->where('consultorio_id', $consultorioId)) ->latest('updated_at') ->limit($limit) ->get() ->map(fn($p) => [ 'fecha' => $p->updated_at, 'tipo' => 'propuesta_' . strtolower($p->estado->value), 'descripcion' => "Propuesta #{$p->numero} " . strtolower($p->estado->value), 'monto' => $p->total, 'paciente' => $p->paciente->full_name, 'odontologo' => $p->creador?->name, ]); $pagos = Pago::with('paciente', 'usuario') ->where('anulado', false) ->when($consultorioId, fn($q) => $q->whereHas('paciente', fn($q2) => $q2->where('consultorio_id', $consultorioId))) ->latest('created_at') ->limit($limit) ->get() ->map(fn($p) => [ 'fecha' => $p->created_at, 'tipo' => 'pago', 'descripcion' => 'Pago registrado', 'monto' => $p->monto, 'paciente' => $p->paciente->full_name, 'odontologo' => $p->usuario?->name, ]); return $propuestas->concat($pagos) ->sortByDesc('fecha') ->take($limit) ->values(); } // Métodos auxiliares para cálculos de variación private function calcularVariacionFacturacion(?int $consultorioId): float { // ... implementación return 12.5; // placeholder } private function calcularVariacionDeuda(?int $consultorioId): float { // ... implementación return -5.2; // placeholder } private function calcularVariacionPacientes(?int $consultorioId): float { // ... implementación return 15.0; // placeholder } private function calcularVariacionConversion(?int $consultorioId): float { // ... implementación return 3.0; // placeholder } private function getMaxFacturadoOdontologo(): float { return DB::table('tratamiento_propuestas') ->where('estado', 'ACEPTADA') ->select('creado_por', DB::raw('SUM(total) as total')) ->groupBy('creado_por') ->orderBy('total', 'desc') ->value('total') ?? 0; } }
🎯 Controlador del Dashboard
php
// app/Http/Controllers/DashboardController.php <?php namespace App\Http\Controllers; use App\Services\DashboardService; use Inertia\Inertia; class DashboardController extends Controller { public function __construct( private DashboardService $dashboardService ) { } public function gerencial() { // Obtener el consultorio de la sesión $consultorioId = session()->get(config('constant.CONSULTORIO_SESSION_KEY')); $data = [ 'kpis' => $this->dashboardService->getKPIs($consultorioId), 'evolucion_facturacion' => $this->dashboardService->getEvolucionFacturacion(6, $consultorioId), 'tratamientos_frecuentes' => $this->dashboardService->getTratamientosFrecuentes(5, $consultorioId), 'productividad_odontologos' => $this->dashboardService->getProductividadOdontologos($consultorioId), 'ultimos_movimientos' => $this->dashboardService->getUltimosMovimientos(10, $consultorioId), ]; return Inertia::render('Dashboard/Gerencial', $data); } }
📊 Componente KPICard (React)
tsx
// resources/js/Components/Dashboard/KPICard.tsx import React from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ArrowUp, ArrowDown } from 'lucide-react'; import { formatCurrency } from '@/lib/utils'; interface Props { title: string; value: number | string; prefix?: string; suffix?: string; variation?: number; icon?: React.ReactNode; } export default function KPICard({ title, value, prefix, suffix, variation, icon }: Props) { const isPositive = variation && variation > 0; const isNegative = variation && variation < 0; const formattedValue = typeof value === 'number' && prefix === '$' ? formatCurrency(value) : typeof value === 'number' ? value.toLocaleString('es-AR') : value; return ( <Card> <CardHeader className="flex flex-row items-center justify-between pb-2"> <CardTitle className="text-sm font-medium text-muted-foreground"> {title} </CardTitle> {icon && <div className="text-muted-foreground">{icon}</div>} </CardHeader> <CardContent> <div className="text-2xl font-bold"> {prefix}{formattedValue}{suffix} </div> {variation !== undefined && ( <div className={`flex items-center text-xs ${isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-muted-foreground'}`}> {isPositive && <ArrowUp className="h-3 w-3 mr-1" />} {isNegative && <ArrowDown className="h-3 w-3 mr-1" />} <span>{Math.abs(variation)}%</span> <span className="text-muted-foreground ml-1">vs mes anterior</span> </div> )} </CardContent> </Card> ); }
📊 Pantalla Gerencial (React)
tsx
// resources/js/Pages/Dashboard/Gerencial.tsx import React from 'react'; import DashboardLayout from '@/Layouts/DashboardLayout'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import KPICard from '@/Components/Dashboard/KPICard'; import { DollarSign, Users, FileText, TrendingUp } from 'lucide-react'; import { formatCurrency } from '@/lib/utils'; interface Props { kpis: { facturacion_total: number; facturacion_vs_mes_anterior: number; deuda_total: number; deuda_vs_mes_anterior: number; pacientes_activos: number; pacientes_vs_mes_anterior: number; tasa_conversion: number; tasa_conversion_vs_mes_anterior: number; periodo: string; }; evolucion_facturacion: { label: string; facturacion: number }[]; tratamientos_frecuentes: { tratamiento: string; cantidad: number; porcentaje: number; total_ingresado: number }[]; productividad_odontologos: { odontologo: string; propuestas_aceptadas: number; total_facturado: number; porcentaje: number }[]; ultimos_movimientos: { fecha: string; tipo: string; descripcion: string; monto: number; paciente: string; odontologo?: string }[]; } export default function Gerencial({ kpis, evolucion_facturacion, tratamientos_frecuentes, productividad_odontologos, ultimos_movimientos, }: Props) { return ( <DashboardLayout> <div className="container mx-auto py-6 space-y-6"> {/* Header */} <div className="flex justify-between items-center"> <div> <h1 className="text-2xl font-bold">Dashboard Gerencial</h1> <p className="text-muted-foreground">{kpis.periodo}</p> </div> <div className="flex gap-2"> <select className="border rounded px-3 py-1 text-sm dark:bg-gray-800"> <option>Último mes</option> <option>Último trimestre</option> <option>Último año</option> </select> </div> </div> {/* KPIs */} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <KPICard title="Facturación" value={kpis.facturacion_total} prefix="$" variation={kpis.facturacion_vs_mes_anterior} icon={<DollarSign className="h-4 w-4" />} /> <KPICard title="Deuda" value={kpis.deuda_total} prefix="$" variation={kpis.deuda_vs_mes_anterior} icon={<FileText className="h-4 w-4" />} /> <KPICard title="Pacientes Activos" value={kpis.pacientes_activos} variation={kpis.pacientes_vs_mes_anterior} icon={<Users className="h-4 w-4" />} /> <KPICard title="Tasa de Conversión" value={kpis.tasa_conversion} suffix="%" variation={kpis.tasa_conversion_vs_mes_anterior} icon={<TrendingUp className="h-4 w-4" />} /> </div> {/* Gráfico de Evolución */} <Card> <CardHeader> <CardTitle>Evolución de Facturación</CardTitle> </CardHeader> <CardContent> <div className="h-64 flex items-end justify-between gap-2"> {evolucion_facturacion.map((item) => { const max = Math.max(...evolucion_facturacion.map(i => i.facturacion)); const height = max > 0 ? (item.facturacion / max) * 100 : 0; return ( <div key={item.label} className="flex flex-col items-center flex-1"> <div className="w-full bg-primary rounded-t transition-all duration-300" style={{ height: `${height}%` }} /> <span className="text-xs text-muted-foreground mt-2">{item.label}</span> </div> ); })} </div> </CardContent> </Card> {/* Dos columnas: Tratamientos + Productividad */} <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> {/* Tratamientos frecuentes */} <Card> <CardHeader> <CardTitle>Tratamientos más frecuentes</CardTitle> </CardHeader> <CardContent> <div className="space-y-3"> {tratamientos_frecuentes.map((item) => ( <div key={item.tratamiento}> <div className="flex justify-between text-sm"> <span>{item.tratamiento}</span> <span className="font-medium">{item.porcentaje}%</span> </div> <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <div className="bg-primary rounded-full h-2" style={{ width: `${item.porcentaje}%` }} /> </div> </div> ))} </div> </CardContent> </Card> {/* Productividad odontólogos */} <Card> <CardHeader> <CardTitle>Productividad por odontólogo</CardTitle> </CardHeader> <CardContent> <div className="space-y-3"> {productividad_odontologos.map((item) => ( <div key={item.odontologo}> <div className="flex justify-between text-sm"> <span>{item.odontologo}</span> <span className="font-medium">{item.porcentaje}%</span> </div> <div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"> <div className="bg-green-500 rounded-full h-2" style={{ width: `${item.porcentaje}%` }} /> </div> </div> ))} </div> </CardContent> </Card> </div> {/* Últimos movimientos */} <Card> <CardHeader> <CardTitle>Últimos movimientos importantes</CardTitle> </CardHeader> <CardContent> <div className="space-y-2"> {ultimos_movimientos.map((movimiento, index) => ( <div key={index} className="flex justify-between items-center py-2 border-b last:border-0"> <div> <p className="text-sm font-medium">{movimiento.descripcion}</p> <p className="text-xs text-muted-foreground"> {movimiento.paciente} • {movimiento.odontologo || 'Sistema'} </p> </div> <div className="text-right"> <span className={`text-sm font-medium ${movimiento.monto > 0 ? 'text-green-600' : ''}`}> {formatCurrency(movimiento.monto)} </span> <p className="text-xs text-muted-foreground"> {new Date(movimiento.fecha).toLocaleString()} </p> </div> </div> ))} </div> </CardContent> </Card> </div> </DashboardLayout> ); }
🛣️ Rutas
php
// routes/web.php use App\Http\Controllers\DashboardController; Route::middleware(['web'])->group(function () { Route::get('/dashboard/gerencial', [DashboardController::class, 'gerencial']) ->name('dashboard.gerencial'); });
🚀 Comando para Calcular Métricas Mensuales
php
// app/Console/Commands/CalcularMetricasMensuales.php <?php namespace App\Console\Commands; use Illuminate\Console\Command; use App\Services\DashboardService; use App\Models\MetricaMensual; class CalcularMetricasMensuales extends Command { protected $signature = 'dashboard:calcular-metricas {--mes=} {--anio=}'; protected $description = 'Calcula las métricas mensuales para el dashboard'; public function handle(DashboardService $service) { $mes = $this->option('mes') ?? now()->month; $anio = $this->option('anio') ?? now()->year; // ... lógica para calcular y guardar en MetricaMensual $this->info("Métricas calculadas para {$mes}/{$anio}"); } }
✅ Resumen de Implementación
| Componente | Estado |
|---|---|
Migración metricas_mensuales | Pendiente |
DashboardService | Pendiente |
DashboardController | Pendiente |
| KPICard (React) | Pendiente |
Pantalla Gerencial.tsx | Pendiente |
| Gráficos y visualizaciones | Pendiente |
Ruta /dashboard/gerencial | Pendiente |
| Comando de cálculo | Pendiente |
¿Por dónde querés empezar? ¿Migración, Service, Controlador, o componente React?
- Obtener enlace
- X
- Correo electrónico
- Otras aplicaciones
- Obtener enlace
- X
- Correo electrónico
- Otras aplicaciones
Comentarios