feat: implement main dashboard navigation and eNPS survey module with gauge visualization
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user