feat: implement main dashboard navigation and eNPS survey module with gauge visualization

This commit is contained in:
2026-07-27 16:56:01 -04:00
parent 210063198b
commit 379342e696
8 changed files with 963 additions and 27 deletions
+347
View File
@@ -0,0 +1,347 @@
import React, { useState, useEffect, useMemo } from 'react';
import { supabaseData } from '../services/supabaseClient';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell } from 'recharts';
import NavBar from '../components/NavBar';
const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#6B8FA3', '#3E6B8A', '#8FBF5A'];
export default function DashboardGLMWAY() {
const [rawData, setRawData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filtroAnio, setFiltroAnio] = useState('');
const [filtroMes, setFiltroMes] = useState('');
const [filtroPais, setFiltroPais] = useState('');
const [filtroDivision, setFiltroDivision] = useState('');
const [filtroCoordinador, setFiltroCoordinador] = useState('');
const [filtroFacilitador, setFiltroFacilitador] = useState('');
const [evaluaciones, setEvaluaciones] = useState([]);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
setLoading(true);
setError(null);
console.log('[DashboardGLMWAY] Iniciando fetch...');
const [inscritosResult, evaluacionesResult] = await Promise.all([
supabaseData
.from('inscritos_glmway')
.select('*')
.order('fecha_ingreso', { ascending: false }),
supabaseData
.from('glmway_evaluacion_kpi')
.select('evaluaciones_enviadas, evaluaciones_resueltas'),
]);
if (inscritosResult.error) {
console.error('[DashboardGLMWAY] Error de Supabase:', {
message: inscritosResult.error.message,
details: inscritosResult.error.details,
hint: inscritosResult.error.hint,
code: inscritosResult.error.code,
});
setError(inscritosResult.error.message || 'Error al cargar los datos de empleados.');
} else {
console.log('[DashboardGLMWAY] Datos recibidos:', inscritosResult.data?.length ?? 0, 'registros');
setRawData(inscritosResult.data ?? []);
}
if (evaluacionesResult.error) {
console.error('[DashboardGLMWAY] Error evaluaciones:', evaluacionesResult.error);
} else {
setEvaluaciones(evaluacionesResult.data ?? []);
}
setLoading(false);
};
const aniosDisponibles = useMemo(() => {
const anios = new Set();
rawData.forEach(item => {
if (item.fecha_ingreso) {
const anio = item.fecha_ingreso.substring(0, 4);
anios.add(anio);
}
});
return Array.from(anios).sort((a, b) => b - a);
}, [rawData]);
const mesesDisponibles = useMemo(() => {
let datosFiltrados = rawData;
if (filtroAnio) {
datosFiltrados = datosFiltrados.filter(item => item.fecha_ingreso?.startsWith(filtroAnio));
}
const meses = new Set();
datosFiltrados.forEach(item => {
if (item.fecha_ingreso) {
const mes = item.fecha_ingreso.substring(5, 7);
meses.add(mes);
}
});
return Array.from(meses).sort();
}, [rawData, filtroAnio]);
const paisesDisponibles = useMemo(() => {
const paises = new Set();
rawData.forEach(item => { if (item.pais) paises.add(item.pais); });
return Array.from(paises).sort();
}, [rawData]);
const divisionesDisponibles = useMemo(() => {
const divisiones = new Set();
rawData.forEach(item => { if (item.division) divisiones.add(item.division); });
return Array.from(divisiones).sort();
}, [rawData]);
const coordinadoresDisponibles = useMemo(() => {
const coordinadores = new Set();
rawData.forEach(item => { if (item.coordinador_rrhh) coordinadores.add(item.coordinador_rrhh); });
return Array.from(coordinadores).sort();
}, [rawData]);
const facilitadoresDisponibles = useMemo(() => {
const facilitadores = new Set();
rawData.forEach(item => { if (item.instructor) facilitadores.add(item.instructor); });
return Array.from(facilitadores).sort();
}, [rawData]);
const datosFiltrados = useMemo(() => {
return rawData.filter(item => {
if (!item.fecha_ingreso) return false;
if (filtroAnio && !item.fecha_ingreso.startsWith(filtroAnio)) return false;
if (filtroMes && !item.fecha_ingreso.substring(5, 7).startsWith(filtroMes)) return false;
if (filtroPais && item.pais !== filtroPais) return false;
if (filtroDivision && item.division !== filtroDivision) return false;
if (filtroCoordinador && item.coordinador_rrhh !== filtroCoordinador) return false;
if (filtroFacilitador && item.instructor !== filtroFacilitador) return false;
return true;
});
}, [rawData, filtroAnio, filtroMes, filtroPais, filtroDivision, filtroCoordinador, filtroFacilitador]);
const mesesNombres = {
'01': 'Enero', '02': 'Febrero', '03': 'Marzo', '04': 'Abril',
'05': 'Mayo', '06': 'Junio', '07': 'Julio', '08': 'Agosto',
'09': 'Septiembre', '10': 'Octubre', '11': 'Noviembre', '12': 'Diciembre'
};
const totalEmpleados = datosFiltrados.length;
const totalParticipantes = useMemo(() => {
return datosFiltrados.filter(item => item.asistio_evento_glmway === true).length;
}, [datosFiltrados]);
const totalEvaluacionesEnviadas = useMemo(() => {
return evaluaciones.reduce((sum, item) => sum + (item.evaluaciones_enviadas || 0), 0);
}, [evaluaciones]);
const totalEvaluacionesResueltas = useMemo(() => {
return evaluaciones.reduce((sum, item) => sum + (item.evaluaciones_resueltas || 0), 0);
}, [evaluaciones]);
const rankingCoordinadores = useMemo(() => {
const conteo = {};
datosFiltrados.forEach(item => {
if (item.coordinador_rrhh && item.asistio_evento_glmway === true) {
conteo[item.coordinador_rrhh] = (conteo[item.coordinador_rrhh] || 0) + 1;
}
});
return Object.entries(conteo)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
}, [datosFiltrados]);
if (loading) return (
<div className="p-10 text-center">
<div className="inline-block w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-xl font-semibold text-azul-glm">Cargando datos...</p>
</div>
);
if (error) return (
<div className="p-10 max-w-xl mx-auto min-h-screen flex items-center justify-center">
<div className="bg-white p-8 rounded-2xl border border-red-200 shadow-sm text-center w-full">
<div className="text-red-500 text-4xl mb-4"></div>
<h2 className="text-xl font-bold text-red-700 mb-2">Error al cargar datos</h2>
<p className="text-sm text-gray-600 mb-1">{error}</p>
<p className="text-xs text-gray-400 mb-6">Revisa la consola del navegador (F12) para más detalles.</p>
<button
onClick={fetchData}
className="bg-azul-glm hover:bg-azul-medio text-white font-semibold py-2.5 px-6 rounded-lg transition-colors"
>
Reintentar
</button>
</div>
</div>
);
return (
<div className="p-6 max-w-7xl mx-auto min-h-screen">
{/* HEADER GLM */}
<header className="flex flex-col sm:flex-row items-center gap-4 mb-8 bg-white p-4 rounded-xl border border-gris-medio/30 shadow-sm">
<div className="flex-shrink-0 bg-verde-bg p-2 rounded-lg">
<img
src="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png"
alt="Logo GLM"
className="h-12 w-auto object-contain max-w-[180px]"
/>
</div>
<div className="text-center sm:text-left flex-1">
<h1 className="text-2xl font-bold text-azul-glm tracking-tight">Dashboard GLMWAY</h1>
<p className="text-xs text-acero italic">Fecha de ingreso de empleados</p>
</div>
</header>
{/* NAVEGACIÓN GLM */}
<NavBar />
{/* FILTROS GLM */}
<div className="flex flex-wrap gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Año</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroAnio}
onChange={(e) => { setFiltroAnio(e.target.value); setFiltroMes(''); }}
>
<option value="">Todos los años</option>
{aniosDisponibles.map((anio, idx) => (
<option key={idx} value={anio}>{anio}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Mes</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroMes}
onChange={(e) => setFiltroMes(e.target.value)}
>
<option value="">Todos los meses</option>
{mesesDisponibles.map((mes, idx) => (
<option key={idx} value={mes}>{mesesNombres[mes]}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">País</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroPais}
onChange={(e) => setFiltroPais(e.target.value)}
>
<option value="">Todos los países</option>
{paisesDisponibles.map((p, idx) => (
<option key={idx} value={p}>{p}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">División</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroDivision}
onChange={(e) => setFiltroDivision(e.target.value)}
>
<option value="">Todas las divisiones</option>
{divisionesDisponibles.map((d, idx) => (
<option key={idx} value={d}>{d}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Coordinador RH</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroCoordinador}
onChange={(e) => setFiltroCoordinador(e.target.value)}
>
<option value="">Todos los coordinadores</option>
{coordinadoresDisponibles.map((c, idx) => (
<option key={idx} value={c}>{c}</option>
))}
</select>
</div>
<div className="flex-1 min-w-[150px]">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Facilitador</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroFacilitador}
onChange={(e) => setFiltroFacilitador(e.target.value)}
>
<option value="">Todos los facilitadores</option>
{facilitadoresDisponibles.map((f, idx) => (
<option key={idx} value={f}>{f}</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={() => { setFiltroAnio(''); setFiltroMes(''); setFiltroPais(''); setFiltroDivision(''); setFiltroCoordinador(''); setFiltroFacilitador(''); }}
className="w-full md:w-auto bg-verde-bg hover:bg-verde-glm hover:text-white text-azul-glm font-semibold text-sm py-2.5 px-5 rounded-lg border border-verde-glm/40 hover:border-verde-glm transition-colors"
>
Limpiar Filtros
</button>
</div>
</div>
{/* KPI GLM */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-azul-glm">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Total Empleados</h2>
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalEmpleados}</p>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-verde-glm">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Participaron en Sesiones</h2>
<p className="text-3xl font-extrabold text-verde-glm mt-1">
{totalEmpleados > 0 ? Math.round((totalParticipantes / totalEmpleados) * 100) : 0}%
</p>
<p className="text-xs text-acero mt-1">{totalParticipantes} de {totalEmpleados} inscritos</p>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-azul-medio">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Evaluaciones Enviadas</h2>
<p className="text-3xl font-extrabold text-azul-medio mt-1">{totalEvaluacionesEnviadas}</p>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-verde-glm">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Evaluaciones Completadas</h2>
<p className="text-3xl font-extrabold text-verde-glm mt-1">{totalEvaluacionesResueltas}</p>
</div>
</div>
{/* RANKING COORDINADORES RH */}
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">
Ranking de Coordinadores RH
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={rankingCoordinadores} margin={{ top: 10, right: 20, left: 20, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis
dataKey="name"
interval={0}
angle={-30}
textAnchor="end"
height={70}
tick={{ fontSize: 11, fill: '#4F758B' }}
/>
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<Tooltip
formatter={(value) => [value, 'Empleados']}
/>
<Bar dataKey="value" name="Empleados" radius={[4, 4, 0, 0]}>
{rankingCoordinadores.map((entry, index) => (
<Cell key={index} fill={CHART_PALETTE[index % CHART_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}