diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/App.jsx b/src/App.jsx index 8ed641d..a143968 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,6 +2,8 @@ import React from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import Dashboard from './screens/DashboardTerminacion'; import DashboardCarnet from './screens/DashboardCarnet'; +import DashboardGLMWAY from './screens/DashboardGLMWAY'; +import DashboardENPS from './screens/DashboardENPS'; import Login from './screens/Login'; import ProtectedRoute from './components/ProtectedRoute'; @@ -26,6 +28,23 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + } /> } /> Carnets + `${linkBase} ${isActive ? linkActive : linkInactive}`} + > + GLMWAY + + `${linkBase} ${isActive ? linkActive : linkInactive}`} + > + eNPS + ); diff --git a/src/screens/DashboardENPS.jsx b/src/screens/DashboardENPS.jsx new file mode 100644 index 0000000..2c981a1 --- /dev/null +++ b/src/screens/DashboardENPS.jsx @@ -0,0 +1,583 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { supabaseData } from '../services/supabaseClient'; +import NavBar from '../components/NavBar'; + +function GaugeChart({ value }) { + const clamped = Math.max(-100, Math.min(100, value || 0)); + const angle = (clamped + 100) / 200 * 180 - 90; + const rad = angle * Math.PI / 180; + const cx = 90, cy = 120, r = 90; + const nx = cx + r * Math.cos(rad); + const ny = cy + r * Math.sin(rad); + + const getColor = (v) => { + if (v >= 50) return '#6CC24A'; + if (v >= 10) return '#F0C75E'; + if (v >= -10) return '#FF8C42'; + return '#FF6A13'; + }; + const color = getColor(clamped); + + const getLabel = (v) => { + if (v >= 50) return 'Excelente'; + if (v >= 10) return 'Bueno'; + if (v >= -10) return 'Regular'; + return 'Crítico'; + }; + + const categories = [ + { from: -100, to: -10, label: 'Crítico', color: '#FF6A13' }, + { from: -10, to: 10, label: 'Regular', color: '#FF8C42' }, + { from: 10, to: 50, label: 'Bueno', color: '#F0C75E' }, + { from: 50, to: 100, label: 'Excelente', color: '#6CC24A' }, + ]; + + function arcPath(sAngle, eAngle, outerR, innerR) { + const sRad = sAngle * Math.PI / 180; + const eRad = eAngle * Math.PI / 180; + const x1 = cx + outerR * Math.cos(sRad); + const y1 = cy + outerR * Math.sin(sRad); + const x2 = cx + outerR * Math.cos(eRad); + const y2 = cy + outerR * Math.sin(eRad); + const x3 = cx + innerR * Math.cos(eRad); + const y3 = cy + innerR * Math.sin(eRad); + const x4 = cx + innerR * Math.cos(sRad); + const y4 = cy + innerR * Math.sin(sRad); + const large = (eAngle - sAngle) > 180 ? 1 : 0; + return `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4} Z`; + } + + const bandSize = 12; + const innerR = r - bandSize; + const totalSegments = 40; + + return ( +
+ + {categories.map((cat, i) => { + const startAngle = (cat.from + 100) / 200 * 180 - 90; + const endAngle = (cat.to + 100) / 200 * 180 - 90; + const segStart = Math.floor((startAngle + 90) / (180 / totalSegments)); + const segEnd = Math.ceil((endAngle + 90) / (180 / totalSegments)); + const segments = []; + for (let s = segStart; s <= segEnd; s++) { + const a1 = Math.max(startAngle, -90 + s * (180 / totalSegments)); + const a2 = Math.min(endAngle, -90 + (s + 1) * (180 / totalSegments)); + if (a2 > a1) { + segments.push(arcPath(a1, a2, r, innerR)); + } + } + return segments.map((d, idx) => ( + + )); + })} + + + + +
+ {clamped} + eNPS +

{getLabel(clamped)}

+
+
+ ); +} + +export default function DashboardENPS() { + const [archivo, setArchivo] = useState(null); + const [subiendo, setSubiendo] = useState(false); + const [mensaje, setMensaje] = useState({ tipo: '', texto: '' }); + const [previewData, setPreviewData] = useState(null); + + const [rawData, setRawData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [filtroAnio, setFiltroAnio] = useState(''); + const [filtroMes, setFiltroMes] = useState(''); + + useEffect(() => { + fetchData(); + }, []); + + const fetchData = async () => { + setLoading(true); + setError(null); + + const { data, error: fetchError } = await supabaseData + .from('encuesta_satisfaccion_glm') + .select('score, text_response, created_at') + .order('created_at', { ascending: false }); + + if (fetchError) { + setError(fetchError.message || 'Error al cargar datos de encuestas.'); + } else { + setRawData(data ?? []); + } + + setLoading(false); + }; + + const anios = useMemo(() => { + const years = new Set(); + rawData.forEach(item => { + if (item.created_at) { + const y = new Date(item.created_at).getFullYear(); + if (!isNaN(y)) years.add(y); + } + }); + return [...years].sort((a, b) => b - a); + }, [rawData]); + + const meses = useMemo(() => { + const months = new Set(); + rawData.forEach(item => { + if (item.created_at) { + const m = new Date(item.created_at).getMonth() + 1; + if (!isNaN(m)) months.add(m); + } + }); + return [...months].sort((a, b) => a - b); + }, [rawData]); + + const filteredData = useMemo(() => { + let result = rawData; + if (filtroAnio) { + result = result.filter(item => { + const y = item.created_at ? new Date(item.created_at).getFullYear() : null; + return y !== null && !isNaN(y) && y === Number(filtroAnio); + }); + } + if (filtroMes) { + result = result.filter(item => { + const m = item.created_at ? new Date(item.created_at).getMonth() + 1 : null; + return m !== null && !isNaN(m) && m === Number(filtroMes); + }); + } + return result; + }, [rawData, filtroAnio, filtroMes]); + + const enpsScore = useMemo(() => { + if (filteredData.length === 0) return 0; + let promoters = 0, detractors = 0; + filteredData.forEach(item => { + const s = Number(item.score); + if (s >= 9) promoters++; + else if (s <= 6) detractors++; + }); + return Math.round(((promoters - detractors) / filteredData.length) * 100); + }, [filteredData]); + + const totalPromoters = useMemo(() => filteredData.filter(d => Number(d.score) >= 9).length, [filteredData]); + const totalPassives = useMemo(() => filteredData.filter(d => Number(d.score) >= 7 && Number(d.score) <= 8).length, [filteredData]); + const totalDetractors = useMemo(() => filteredData.filter(d => Number(d.score) <= 6).length, [filteredData]); + + const comentariosConTexto = useMemo(() => { + return filteredData.filter(d => d.text_response && d.text_response.trim() !== ''); + }, [filteredData]); + + const [paginaComentarios, setPaginaComentarios] = useState(1); + useEffect(() => { setPaginaComentarios(1); }, [filtroAnio, filtroMes]); + const comentariosPorPagina = 14; + const totalPaginasComentarios = Math.ceil(comentariosConTexto.length / comentariosPorPagina) || 1; + const comentariosPaginados = useMemo(() => { + const start = (paginaComentarios - 1) * comentariosPorPagina; + return comentariosConTexto.slice(start, start + comentariosPorPagina); + }, [comentariosConTexto, paginaComentarios]); + + const nombreMes = (m) => { + const nombres = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + return nombres[m - 1] || m; + }; + + const validarArchivo = (file) => { + const extensionesPermitidas = ['.csv', '.xlsx', '.xls']; + const extension = '.' + file.name.split('.').pop().toLowerCase(); + if (!extensionesPermitidas.includes(extension)) { + return 'Formato no válido. Solo se permiten archivos .csv, .xlsx, .xls'; + } + if (file.size > 10 * 1024 * 1024) { + return 'El archivo es demasiado grande. Máximo 10MB.'; + } + return null; + }; + + const procesarArchivo = (file) => { + const error = validarArchivo(file); + if (error) { + setMensaje({ tipo: 'error', texto: error }); + setArchivo(null); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const extension = '.' + file.name.split('.').pop().toLowerCase(); + let datos; + + if (extension === '.csv') { + const texto = e.target.result; + const lineas = texto.trim().split('\n'); + const headers = lineas[0].split(',').map(h => h.trim().replace(/"/g, '')); + + if (!headers.includes('Score') || !headers.includes('Text Response')) { + setMensaje({ tipo: 'error', texto: 'El CSV debe contener las columnas "Score" y "Text Response"' }); + setArchivo(null); + return; + } + + datos = lineas.slice(1).map(linea => { + const valores = linea.split(',').map(v => v.trim().replace(/"/g, '')); + const obj = {}; + headers.forEach((h, i) => obj[h] = valores[i]); + return obj; + }); + } else { + setMensaje({ tipo: 'error', texto: 'Archivos Excel requieren librería adicional. Use CSV por ahora.' }); + setArchivo(null); + return; + } + + setPreviewData(datos.slice(0, 5)); + setMensaje({ tipo: 'success', texto: `${datos.length} registros válidos encontrados` }); + } catch (err) { + setMensaje({ tipo: 'error', texto: 'Error al procesar el archivo: ' + err.message }); + setArchivo(null); + } + }; + reader.readAsText(file); + }; + + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (file) { + setArchivo(file); + setMensaje({ tipo: '', texto: '' }); + setPreviewData(null); + procesarArchivo(file); + } + }; + + const enviarAWebhook = async () => { + if (!archivo || !previewData) { + setMensaje({ tipo: 'error', texto: 'No hay archivo para enviar' }); + return; + } + + setSubiendo(true); + setMensaje({ tipo: '', texto: '' }); + + try { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const texto = e.target.result; + const lineas = texto.trim().split('\n'); + const headers = lineas[0].split(',').map(h => h.trim().replace(/"/g, '')); + + const datos = lineas.slice(1).map(linea => { + const valores = linea.split(',').map(v => v.trim().replace(/"/g, '')); + const obj = {}; + headers.forEach((h, i) => obj[h] = valores[i]); + return obj; + }); + + const webhookUrl = import.meta.env.VITE_ENCUESTA_SATISFACCION_WEBHOOK; + const secret = import.meta.env.VITE_ENCUESTA_SATISFACCION_WEBHOOK_SECRET; + + if (!webhookUrl || !secret) { + setMensaje({ tipo: 'error', texto: 'Configuración de webhook no encontrada en variables de entorno' }); + setSubiendo(false); + return; + } + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': secret + }, + body: JSON.stringify({ data: datos }) + }); + + if (response.ok) { + setMensaje({ tipo: 'success', texto: `${datos.length} registros enviados correctamente al webhook` }); + setArchivo(null); + setPreviewData(null); + fetchData(); + } else { + const errorText = await response.text(); + setMensaje({ tipo: 'error', texto: `Error del webhook (${response.status}): ${errorText}` }); + } + } catch (err) { + setMensaje({ tipo: 'error', texto: 'Error al enviar: ' + err.message }); + } finally { + setSubiendo(false); + } + }; + reader.readAsText(archivo); + } catch (err) { + setMensaje({ tipo: 'error', texto: 'Error: ' + err.message }); + setSubiendo(false); + } + }; + + if (loading) return ( +
+
+

Cargando datos...

+
+ ); + + if (error) return ( +
+
+
+

Error al cargar datos

+

{error}

+

Revisa la consola del navegador (F12) para más detalles.

+ +
+
+ ); + + return ( +
+
+
+ Logo GLM +
+
+

Dashboard eNPS

+

Métricas de satisfacción y comentarios

+
+
+ + + +
+

Subir archivo de encuesta

+ +
+
+ + +
+ + {mensaje.texto && ( +
+ {mensaje.texto} +
+ )} + + {previewData && previewData.length > 0 && ( +
+
+

Vista previa (primeros 5 registros)

+
+
+ + + + + + + + + {previewData.map((row, idx) => ( + + + + + ))} + +
ScoreText Response
{row['Score'] || ''}{row['Text Response'] || ''}
+
+
+ )} + + +
+
+ +
+
+
+

eNPS General

+ +
+
+
+
+

Distribución de Respuestas

+
+
+

{totalPromoters}

+

Promotores

+

(9-10)

+
+
+

{totalPassives}

+

Pasivos

+

(7-8)

+
+
+

{totalDetractors}

+

Detractores

+

(0-6)

+
+
+
+ Total respuestas: + {filteredData.length} +
+
+
+
+ +
+

Filtros

+
+
+ + +
+
+ + +
+ +
+
+ +
+
+

Comentarios

+
+ {comentariosConTexto.length === 0 ? ( +

No hay comentarios para los filtros seleccionados.

+ ) : ( + <> +
+ + + + + + + + + + {comentariosPaginados.map((item, idx) => { + const s = Number(item.score); + let badge = 'bg-gray-100 text-gray-700'; + if (s >= 9) badge = 'bg-green-100 text-green-700'; + else if (s <= 6) badge = 'bg-red-100 text-red-600'; + return ( + + + + + + ); + })} + +
FechaScoreComentario
+ {item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: '2-digit', day: '2-digit' }) : '-'} + + + {item.score} + + {item.text_response}
+
+
+
+ Mostrando {comentariosConTexto.length > 0 ? (paginaComentarios - 1) * comentariosPorPagina + 1 : 0} - {Math.min(paginaComentarios * comentariosPorPagina, comentariosConTexto.length)} de {comentariosConTexto.length} comentarios +
+
+ + + Página {paginaComentarios} de {totalPaginasComentarios} + + +
+
+ + )} +
+
+ ); +} diff --git a/src/screens/DashboardGLMWAY.jsx b/src/screens/DashboardGLMWAY.jsx new file mode 100644 index 0000000..4da6e9c --- /dev/null +++ b/src/screens/DashboardGLMWAY.jsx @@ -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 ( +
+
+

Cargando datos...

+
+ ); + + if (error) return ( +
+
+
⚠️
+

Error al cargar datos

+

{error}

+

Revisa la consola del navegador (F12) para más detalles.

+ +
+
+ ); + + return ( +
+ + {/* HEADER GLM */} +
+
+ Logo GLM +
+
+

Dashboard GLMWAY

+

Fecha de ingreso de empleados

+
+
+ + {/* NAVEGACIÓN GLM */} + + + {/* FILTROS GLM */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + {/* KPI GLM */} +
+
+

Total Empleados

+

{totalEmpleados}

+
+
+

Participaron en Sesiones

+

+ {totalEmpleados > 0 ? Math.round((totalParticipantes / totalEmpleados) * 100) : 0}% +

+

{totalParticipantes} de {totalEmpleados} inscritos

+
+
+

Evaluaciones Enviadas

+

{totalEvaluacionesEnviadas}

+
+
+

Evaluaciones Completadas

+

{totalEvaluacionesResueltas}

+
+
+ + {/* RANKING COORDINADORES RH */} +
+

+ Ranking de Coordinadores RH +

+
+ + + + + + [value, 'Empleados']} + /> + + {rankingCoordinadores.map((entry, index) => ( + + ))} + + + +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/screens/DashboardTerminacion.jsx b/src/screens/DashboardTerminacion.jsx index aa8888a..e3d69fb 100644 --- a/src/screens/DashboardTerminacion.jsx +++ b/src/screens/DashboardTerminacion.jsx @@ -209,7 +209,7 @@ export default function Dashboard() { />
-

Dashboard de Estadísticas

+

Dashboard de Encuestas de Terminación

Métricas analíticas de entrevistas de salida

diff --git a/vite.config.js b/vite.config.js index 071af14..0d2cabd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,5 +5,5 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [tailwindcss(), react()], - base: '/dashboard/encuesta-terminacion' + base: '/dashboard/' })