Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71b70e78ee | |||
| 379342e696 | |||
| 210063198b | |||
| 2e4031e14a |
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/jpeg" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_white_background.jpg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>dashboard-proyectos</title>
|
<title>dashboard-proyectos</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
+42
-4
@@ -1,22 +1,60 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Dashboard from './Dashboard';
|
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';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/dashboard/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/dashboard/encuesta-terminacion"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/carnet"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardCarnet />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/glmway"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardGLMWAY />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/dashboard/enps"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<DashboardENPS />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/dashboard/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
|
||||||
<Route path="/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
|
||||||
<Route path="/dashboard/encuesta-terminacion" element={<Dashboard />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gris-claro text-gris-oscuro p-6">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gris-claro text-gris-oscuro p-6">
|
||||||
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gris-medio/30 text-center max-w-md w-full">
|
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gris-medio/30 text-center max-w-md w-full">
|
||||||
<h1 className="text-6xl font-extrabold text-azul-glm mb-4">404</h1>
|
<h1 className="text-6xl font-extrabold text-azul-glm mb-4">404</h1>
|
||||||
<h2 className="text-xl font-bold text-gris-oscuro mb-2">Página no encontrada</h2>
|
<h2 className="text-xl font-bold text-gris-oscuro mb-2">Pagina no encontrada</h2>
|
||||||
<p className="text-acero mb-6">
|
<p className="text-acero mb-6">
|
||||||
La página que buscas no existe o ha sido movida.
|
La pagina que buscas no existe o ha sido movida.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/dashboard/encuesta-terminacion"
|
href="/dashboard/encuesta-terminacion"
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { supabase } from './services/supabaseClient';
|
|
||||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
||||||
|
|
||||||
const GLM_COLORS = { positivo: '#6CC24A', neutro: '#F0C75E', negativo: '#FF6A13' };
|
|
||||||
const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#6B8FA3'];
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [rawData, setRawData] = useState([]);
|
|
||||||
const [filteredData, setFilteredData] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const [posiciones, setPosiciones] = useState([]);
|
|
||||||
const [homologaciones, setHomologaciones] = useState([]);
|
|
||||||
const [paises, setPaises] = useState([]);
|
|
||||||
|
|
||||||
const [filtroPosicion, setFiltroPosicion] = useState('');
|
|
||||||
const [filtroHomologacion, setFiltroHomologacion] = useState('');
|
|
||||||
const [filtroPais, setFiltroPais] = useState('');
|
|
||||||
|
|
||||||
const [paginaActual, setPaginaActual] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
aplicarFiltros();
|
|
||||||
setPaginaActual(1);
|
|
||||||
}, [filtroPosicion, filtroHomologacion, filtroPais, rawData]);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('encuesta_terminacion_glm')
|
|
||||||
.select('*')
|
|
||||||
.order('timestamp', { ascending: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching data:', error);
|
|
||||||
} else {
|
|
||||||
setRawData(data);
|
|
||||||
const uniquePosiciones = [...new Set(data.map(item => item.posicion).filter(Boolean))];
|
|
||||||
const uniqueHomologaciones = [...new Set(data.map(item => item.homologacion_ia).filter(Boolean))];
|
|
||||||
const uniquePaises = [...new Set(data.map(item => item.pais).filter(Boolean))];
|
|
||||||
setPosiciones(uniquePosiciones);
|
|
||||||
setHomologaciones(uniqueHomologaciones);
|
|
||||||
setPaises(uniquePaises);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const aplicarFiltros = () => {
|
|
||||||
let result = rawData;
|
|
||||||
if (filtroPosicion) result = result.filter(item => item.posicion === filtroPosicion);
|
|
||||||
if (filtroHomologacion) result = result.filter(item => item.homologacion_ia === filtroHomologacion);
|
|
||||||
if (filtroPais) result = result.filter(item => item.pais === filtroPais);
|
|
||||||
|
|
||||||
// Ordenar por fecha de más reciente a más vieja
|
|
||||||
result = [...result].sort((a, b) => {
|
|
||||||
const dateA = a.timestamp ? new Date(a.timestamp) : 0;
|
|
||||||
const dateB = b.timestamp ? new Date(b.timestamp) : 0;
|
|
||||||
return dateB - dateA;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFilteredData(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filasPorPagina = 20;
|
|
||||||
const totalPaginas = Math.ceil(filteredData.length / filasPorPagina) || 1;
|
|
||||||
const indiceUltimoItem = paginaActual * filasPorPagina;
|
|
||||||
const indicePrimerItem = indiceUltimoItem - filasPorPagina;
|
|
||||||
const datosPaginados = filteredData.slice(indicePrimerItem, indiceUltimoItem);
|
|
||||||
|
|
||||||
const totalRegistros = filteredData.length;
|
|
||||||
const regresarian = filteredData.filter(d => d.considerar_regresar).length;
|
|
||||||
const recomendarian = filteredData.filter(d => d.siento_comodo_recomendando).length;
|
|
||||||
|
|
||||||
const sentimientoCount = filteredData.reduce((acc, curr) => {
|
|
||||||
const sent = curr.sentimiento_ia || 'neutro';
|
|
||||||
acc[sent] = (acc[sent] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const chartDataSentimiento = Object.keys(sentimientoCount).map(key => ({
|
|
||||||
name: key,
|
|
||||||
value: sentimientoCount[key]
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (loading) return <div className="p-10 text-center text-xl font-semibold text-azul-glm">Cargando datos...</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 de Estadísticas</h1>
|
|
||||||
<p className="text-xs text-acero italic">Métricas analíticas de entrevistas de salida</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* FILTROS GLM */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Posició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={filtroPosicion}
|
|
||||||
onChange={(e) => setFiltroPosicion(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Todas las posiciones</option>
|
|
||||||
{posiciones.map((pos, idx) => (
|
|
||||||
<option key={idx} value={pos}>{pos}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Motivo</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={filtroHomologacion}
|
|
||||||
onChange={(e) => setFiltroHomologacion(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Todos los motivos</option>
|
|
||||||
{homologaciones.map((hom, idx) => (
|
|
||||||
<option key={idx} value={hom}>{hom}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por 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>
|
|
||||||
{paises.map((pais, idx) => (
|
|
||||||
<option key={idx} value={pais}>{pais}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
onClick={() => { setFiltroPosicion(''); setFiltroHomologacion(''); setFiltroPais(''); }}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* KPIs GLM */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 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 Registros</h2>
|
|
||||||
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalRegistros}</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">Consideraría Regresar</h2>
|
|
||||||
<p className="text-3xl font-extrabold text-verde-glm mt-1">
|
|
||||||
{totalRegistros > 0 ? Math.round((regresarian / totalRegistros) * 100) : 0}%
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-acero mt-1">{regresarian} empleados interesados</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">Recomendaría la Empresa</h2>
|
|
||||||
<p className="text-3xl font-extrabold text-azul-medio mt-1">
|
|
||||||
{totalRegistros > 0 ? Math.round((recomendarian / totalRegistros) * 100) : 0}%
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-acero mt-1">{recomendarian} empleados satisfechos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GRÁFICA GLM */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
||||||
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
|
||||||
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Sentimiento del Motivo</h3>
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartDataSentimiento}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
outerRadius={80}
|
|
||||||
fill="#8884d8"
|
|
||||||
dataKey="value"
|
|
||||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
|
||||||
>
|
|
||||||
{chartDataSentimiento.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={GLM_COLORS[entry.name.toLowerCase()] || CHART_PALETTE[index % CHART_PALETTE.length]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TABLA GLM */}
|
|
||||||
<div className="bg-white rounded-xl border border-gris-medio/30 shadow-sm overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg">
|
|
||||||
<h3 className="text-base font-bold text-azul-glm">Detalle de Registros</h3>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gris-medio/50 text-sm">
|
|
||||||
<thead className="bg-azul-glm">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Fecha</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">País</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Posición</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Motivo</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Sentimiento</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gris-medio/30">
|
|
||||||
{datosPaginados.map((item, idx) => (
|
|
||||||
<tr key={idx} className={`${idx % 2 === 0 ? 'bg-white' : 'bg-gris-claro'} hover:bg-verde-bg/50 transition-colors`}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-acero">
|
|
||||||
{new Date(item.timestamp).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap font-semibold text-gris-oscuro">{item.pais}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.posicion}</td>
|
|
||||||
<td className="px-6 py-4 text-gris-oscuro max-w-xs truncate">{item.homologacion_ia}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{(() => {
|
|
||||||
const sent = String(item.sentimiento_ia || '').toLowerCase().trim();
|
|
||||||
let classes = 'bg-gray-100 text-gray-800';
|
|
||||||
if (sent === 'positivo') {
|
|
||||||
classes = 'bg-verde-ok text-green-700';
|
|
||||||
} else if (sent === 'negativo') {
|
|
||||||
classes = 'bg-rojo-suave text-red-600';
|
|
||||||
} else if (sent === 'neutro') {
|
|
||||||
classes = 'bg-amarillo-medio text-yellow-700';
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className={`px-2.5 py-1 inline-flex text-xs leading-5 font-bold rounded-full ${classes}`}>
|
|
||||||
{item.sentimiento_ia}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controles de Pagina */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-4 border-t border-gris-medio/30 bg-gris-claro/50">
|
|
||||||
<div className="text-xs text-acero font-medium">
|
|
||||||
{totalRegistros > 0
|
|
||||||
? `Mostrando registros ${indicePrimerItem + 1} - ${Math.min(indiceUltimoItem, totalRegistros)} de ${totalRegistros}`
|
|
||||||
: 'No hay registros para mostrar'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPaginaActual(prev => Math.max(prev - 1, 1))}
|
|
||||||
disabled={paginaActual === 1}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
Atrás
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-gris-oscuro font-bold">
|
|
||||||
Página {paginaActual} de {totalPaginas}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setPaginaActual(prev => Math.min(prev + 1, totalPaginas))}
|
|
||||||
disabled={paginaActual === totalPaginas}
|
|
||||||
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
|
||||||
>
|
|
||||||
Siguiente
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const linkBase = 'font-semibold text-sm py-2.5 px-5 rounded-lg transition-colors text-center flex-1';
|
||||||
|
const linkActive = 'bg-azul-glm text-white';
|
||||||
|
const linkInactive = 'bg-verde-bg text-azul-glm hover:bg-verde-glm hover:text-white border border-verde-glm/40';
|
||||||
|
|
||||||
|
export default function NavBar() {
|
||||||
|
const { signOut, user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center gap-3 bg-white p-3 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard/encuesta-terminacion"
|
||||||
|
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
|
||||||
|
>
|
||||||
|
Encuesta Terminacion
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard/carnet"
|
||||||
|
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
|
||||||
|
>
|
||||||
|
Carnets
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard/glmway"
|
||||||
|
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
|
||||||
|
>
|
||||||
|
GLMWAY
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/dashboard/enps"
|
||||||
|
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
|
||||||
|
>
|
||||||
|
eNPS
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gris-claro">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin" />
|
||||||
|
<p className="text-acero font-medium">Cargando...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/dashboard/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { supabase } from '../services/supabaseClient';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
const ALLOWED_DOMAIN = 'gomezleemarketing.com';
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
if (session?.user) {
|
||||||
|
const email = session.user.email || '';
|
||||||
|
if (email.endsWith(`@${ALLOWED_DOMAIN}`)) {
|
||||||
|
setUser(session.user);
|
||||||
|
} else {
|
||||||
|
supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||||
|
if (session?.user) {
|
||||||
|
const email = session.user.email || '';
|
||||||
|
if (email.endsWith(`@${ALLOWED_DOMAIN}`)) {
|
||||||
|
setUser(session.user);
|
||||||
|
} else {
|
||||||
|
supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signInWithGoogle = async () => {
|
||||||
|
const { error } = await supabase.auth.signInWithOAuth({
|
||||||
|
provider: 'google',
|
||||||
|
options: {
|
||||||
|
redirectTo: `${window.location.origin}/dashboard/encuesta-terminacion`,
|
||||||
|
queryParams: {
|
||||||
|
hd: ALLOWED_DOMAIN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, signInWithGoogle, signOut }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { supabaseData } from '../services/supabaseClient';
|
||||||
|
import { BarChart, Bar, Cell, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||||
|
import NavBar from '../components/NavBar';
|
||||||
|
|
||||||
|
const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#6B8FA3', '#3E6B8A', '#8FBF5A'];
|
||||||
|
|
||||||
|
export default function DashboardCarnet() {
|
||||||
|
const [rawData, setRawData] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const [filtroDepartamento, setFiltroDepartamento] = useState('');
|
||||||
|
const [filtroPosicion, setFiltroPosicion] = useState('');
|
||||||
|
const [filtroPais, setFiltroPais] = useState('');
|
||||||
|
const [filtroDivision, setFiltroDivision] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper para obtener valores únicos ordenados de una columna (obtiene todos los valores para la lista del filtro)
|
||||||
|
const opcionesUnicas = (campo) => [
|
||||||
|
...new Set(
|
||||||
|
rawData
|
||||||
|
.map(item => (item?.[campo] ?? '').toString().trim())
|
||||||
|
.filter(v => v !== '')
|
||||||
|
),
|
||||||
|
].sort((a, b) => a.localeCompare(b, 'es'));
|
||||||
|
|
||||||
|
const departamentos = useMemo(() => opcionesUnicas('departamento'), [rawData]);
|
||||||
|
const posiciones = useMemo(() => opcionesUnicas('posicion'), [rawData]);
|
||||||
|
const paises = useMemo(() => opcionesUnicas('pais'), [rawData]);
|
||||||
|
const divisiones = useMemo(() => opcionesUnicas('division'), [rawData]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('[DashboardCarnet] Iniciando fetch...');
|
||||||
|
|
||||||
|
const { data, error: fetchError } = await supabaseData
|
||||||
|
.from('carnet_empleados_creados_glm')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error('[DashboardCarnet] Error de Supabase:', {
|
||||||
|
message: fetchError.message,
|
||||||
|
details: fetchError.details,
|
||||||
|
hint: fetchError.hint,
|
||||||
|
code: fetchError.code,
|
||||||
|
});
|
||||||
|
setError(fetchError.message || 'Error al cargar los datos de carnets.');
|
||||||
|
} else {
|
||||||
|
console.log('[DashboardCarnet] Datos recibidos:', data?.length ?? 0, 'registros');
|
||||||
|
setRawData(data ?? []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const datosFiltrados = rawData.filter(item => {
|
||||||
|
const matchDepartamento = !filtroDepartamento || (item?.departamento ?? '').toString().trim() === filtroDepartamento;
|
||||||
|
const matchPosicion = !filtroPosicion || (item?.posicion ?? '').toString().trim() === filtroPosicion;
|
||||||
|
const matchPais = !filtroPais || (item?.pais ?? '').toString().trim() === filtroPais;
|
||||||
|
const matchDivision = !filtroDivision || (item?.division ?? '').toString().trim() === filtroDivision;
|
||||||
|
return matchDepartamento && matchPosicion && matchPais && matchDivision;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCarnets = datosFiltrados.length;
|
||||||
|
|
||||||
|
const limpiarFiltros = () => {
|
||||||
|
setFiltroDepartamento('');
|
||||||
|
setFiltroPosicion('');
|
||||||
|
setFiltroPais('');
|
||||||
|
setFiltroDivision('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hayFiltrosActivos = filtroDepartamento || filtroPosicion || filtroPais || filtroDivision;
|
||||||
|
|
||||||
|
const conteoPorDivision = datosFiltrados.reduce((acc, curr) => {
|
||||||
|
const division = (curr.division || 'Sin división').toString().trim() || 'Sin división';
|
||||||
|
acc[division] = (acc[division] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const chartDataDivision = Object.keys(conteoPorDivision).map((division, index) => ({
|
||||||
|
name: division,
|
||||||
|
value: conteoPorDivision[division],
|
||||||
|
fill: CHART_PALETTE[index % CHART_PALETTE.length]
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 de Carnets</h1>
|
||||||
|
<p className="text-xs text-acero italic">Conteo de carnets creados por división</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* NAVEGACIÓN GLM */}
|
||||||
|
<NavBar />
|
||||||
|
|
||||||
|
{/* FILTROS GLM */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Departamento</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={filtroDepartamento}
|
||||||
|
onChange={(e) => setFiltroDepartamento(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos los departamentos</option>
|
||||||
|
{departamentos.map((dep, idx) => (
|
||||||
|
<option key={idx} value={dep}>{dep}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Posició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={filtroPosicion}
|
||||||
|
onChange={(e) => setFiltroPosicion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todas las posiciones</option>
|
||||||
|
{posiciones.map((pos, idx) => (
|
||||||
|
<option key={idx} value={pos}>{pos}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por 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>
|
||||||
|
{paises.map((pais, idx) => (
|
||||||
|
<option key={idx} value={pais}>{pais}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por 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>
|
||||||
|
{divisiones.map((division, idx) => (
|
||||||
|
<option key={idx} value={division}>{division}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={limpiarFiltros}
|
||||||
|
disabled={!hayFiltrosActivos}
|
||||||
|
className="w-full 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Limpiar Filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI GLM */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 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 Carnets Creados</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalCarnets}</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">Divisiones</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-verde-glm mt-1">{divisiones.length}</p>
|
||||||
|
<p className="text-xs text-acero mt-1">distintas en el histórico</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GRÁFICA GLM */}
|
||||||
|
<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">Carnets por División</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataDivision} margin={{ top: 10, right: 20, left: 20, bottom: 60 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
interval={0}
|
||||||
|
height={70}
|
||||||
|
tick={{ fontSize: 11, fill: '#4F758B' }}
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<Bar dataKey="value" name="Carnets" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartDataDivision.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
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 cx = 120, cy = 100, r = 85;
|
||||||
|
const bandSize = 12;
|
||||||
|
const innerR = r - bandSize;
|
||||||
|
|
||||||
|
const toAngle = (v) => 180 + ((v + 100) / 200) * 180;
|
||||||
|
const needleAngle = toAngle(clamped);
|
||||||
|
const rad = needleAngle * Math.PI / 180;
|
||||||
|
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 totalSegments = 40;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<svg width="240" height="140" viewBox="0 0 240 140">
|
||||||
|
{categories.map((cat, i) => {
|
||||||
|
const startAngle = toAngle(cat.from);
|
||||||
|
const endAngle = toAngle(cat.to);
|
||||||
|
const segSize = 180 / totalSegments;
|
||||||
|
const segStart = Math.floor(startAngle / segSize);
|
||||||
|
const segEnd = Math.ceil(endAngle / segSize);
|
||||||
|
const segments = [];
|
||||||
|
for (let s = segStart; s <= segEnd; s++) {
|
||||||
|
const a1 = Math.max(startAngle, s * segSize);
|
||||||
|
const a2 = Math.min(endAngle, (s + 1) * segSize);
|
||||||
|
if (a2 > a1) {
|
||||||
|
segments.push(arcPath(a1, a2, r, innerR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments.map((d, idx) => (
|
||||||
|
<path key={`${i}-${idx}`} d={d} fill={cat.color} opacity={0.85} />
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
|
||||||
|
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#1E293B" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<circle cx={cx} cy={cy} r="6" fill="#1E293B" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-center -mt-1">
|
||||||
|
<span className="text-4xl font-extrabold" style={{ color }}>{clamped}</span>
|
||||||
|
<span className="text-lg font-bold text-gris-oscuro ml-1">eNPS</span>
|
||||||
|
<p className="text-sm font-semibold text-acero mt-1">{getLabel(clamped)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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 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 eNPS</h1>
|
||||||
|
<p className="text-xs text-acero italic">Métricas de satisfacción y comentarios</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<NavBar />
|
||||||
|
|
||||||
|
<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">Subir archivo de encuesta</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-2">
|
||||||
|
Seleccionar archivo CSV (columnas: Score, Text Response)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={subiendo}
|
||||||
|
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 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mensaje.texto && (
|
||||||
|
<div className={`p-4 rounded-lg text-sm ${mensaje.tipo === 'error'
|
||||||
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{mensaje.texto}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewData && previewData.length > 0 && (
|
||||||
|
<div className="border border-gris-medio/30 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-verde-bg px-4 py-2 border-b border-gris-medio/30">
|
||||||
|
<p className="text-xs font-bold text-azul-glm uppercase">Vista previa (primeros 5 registros)</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-gris-claro">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-bold text-gris-oscuro uppercase">Score</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-bold text-gris-oscuro uppercase">Text Response</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{previewData.map((row, idx) => (
|
||||||
|
<tr key={idx} className={idx % 2 === 0 ? 'bg-white' : 'bg-gris-claro/50'}>
|
||||||
|
<td className="px-4 py-2 text-gris-oscuro">{row['Score'] || ''}</td>
|
||||||
|
<td className="px-4 py-2 text-gris-oscuro max-w-xs truncate">{row['Text Response'] || ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={enviarAWebhook}
|
||||||
|
disabled={subiendo || !archivo}
|
||||||
|
className="w-full md:w-auto bg-azul-glm hover:bg-azul-medio text-white font-semibold py-2.5 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 justify-center"
|
||||||
|
>
|
||||||
|
{subiendo ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Enviando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Enviar Datos'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">eNPS General</h3>
|
||||||
|
<GaugeChart value={enpsScore} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm h-full">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Distribución de Respuestas</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 mt-6">
|
||||||
|
<div className="text-center p-4 rounded-xl bg-gradient-to-br from-green-50 to-green-100 border border-green-200">
|
||||||
|
<p className="text-3xl font-extrabold text-green-600">{totalPromoters}</p>
|
||||||
|
<p className="text-xs font-bold text-green-700 uppercase mt-1">Promotores</p>
|
||||||
|
<p className="text-xs text-green-500">(9-10)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 rounded-xl bg-gradient-to-br from-yellow-50 to-yellow-100 border border-yellow-200">
|
||||||
|
<p className="text-3xl font-extrabold text-yellow-600">{totalPassives}</p>
|
||||||
|
<p className="text-xs font-bold text-yellow-700 uppercase mt-1">Pasivos</p>
|
||||||
|
<p className="text-xs text-yellow-500">(7-8)</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center p-4 rounded-xl bg-gradient-to-br from-orange-50 to-orange-100 border border-orange-200">
|
||||||
|
<p className="text-3xl font-extrabold text-orange-600">{totalDetractors}</p>
|
||||||
|
<p className="text-xs font-bold text-orange-700 uppercase mt-1">Detractores</p>
|
||||||
|
<p className="text-xs text-orange-500">(0-6)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 text-center text-sm text-acero">
|
||||||
|
<span className="font-semibold">Total respuestas: </span>
|
||||||
|
<span className="font-bold text-azul-glm">{filteredData.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Filtros</h3>
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Año</label>
|
||||||
|
<select
|
||||||
|
value={filtroAnio}
|
||||||
|
onChange={(e) => setFiltroAnio(e.target.value)}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{anios.map(a => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Mes</label>
|
||||||
|
<select
|
||||||
|
value={filtroMes}
|
||||||
|
onChange={(e) => setFiltroMes(e.target.value)}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{meses.map(m => (
|
||||||
|
<option key={m} value={m}>{nombreMes(m)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setFiltroAnio(''); setFiltroMes(''); }}
|
||||||
|
className="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>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gris-medio/30 shadow-sm mb-8 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm">Comentarios</h3>
|
||||||
|
</div>
|
||||||
|
{comentariosConTexto.length === 0 ? (
|
||||||
|
<p className="text-sm text-acero text-center py-8">No hay comentarios para los filtros seleccionados.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gris-medio/50 text-sm">
|
||||||
|
<thead className="bg-azul-glm">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Fecha</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Score</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Comentario</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gris-medio/30">
|
||||||
|
{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 (
|
||||||
|
<tr key={idx} className={idx % 2 === 0 ? 'bg-white' : 'bg-gris-claro/30'}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-acero text-xs">
|
||||||
|
{item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX', { year: 'numeric', month: '2-digit', day: '2-digit' }) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-2.5 py-1 text-xs font-bold rounded-full ${badge}`}>
|
||||||
|
{item.score}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gris-oscuro max-w-md">{item.text_response}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-4 border-t border-gris-medio/30 bg-gris-claro/50">
|
||||||
|
<div className="text-xs text-acero font-medium">
|
||||||
|
Mostrando {comentariosConTexto.length > 0 ? (paginaComentarios - 1) * comentariosPorPagina + 1 : 0} - {Math.min(paginaComentarios * comentariosPorPagina, comentariosConTexto.length)} de {comentariosConTexto.length} comentarios
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaginaComentarios(p => Math.max(p - 1, 1))}
|
||||||
|
disabled={paginaComentarios === 1}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Atrás
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gris-oscuro font-bold">
|
||||||
|
Página {paginaComentarios} de {totalPaginasComentarios}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPaginaComentarios(p => Math.min(p + 1, totalPaginasComentarios))}
|
||||||
|
disabled={paginaComentarios === totalPaginasComentarios}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { supabaseData } from '../services/supabaseClient';
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
|
||||||
|
import NavBar from '../components/NavBar';
|
||||||
|
|
||||||
|
const GLM_COLORS = { positivo: '#6CC24A', neutro: '#F0C75E', negativo: '#FF6A13' };
|
||||||
|
const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#6B8FA3'];
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [rawData, setRawData] = useState([]);
|
||||||
|
const [filteredData, setFilteredData] = useState([]);
|
||||||
|
const [encuestasEnviadas, setEncuestasEnviadas] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const opcionesUnicas = (campo) => [
|
||||||
|
...new Set(
|
||||||
|
rawData
|
||||||
|
.map(item => (item?.[campo] ?? '').toString().trim())
|
||||||
|
.filter(v => v !== '')
|
||||||
|
),
|
||||||
|
].sort((a, b) => a.localeCompare(b, 'es'));
|
||||||
|
|
||||||
|
const posiciones = useMemo(() => opcionesUnicas('posicion'), [rawData]);
|
||||||
|
const homologaciones = useMemo(() => opcionesUnicas('homologacion_ia'), [rawData]);
|
||||||
|
const paises = useMemo(() => opcionesUnicas('pais'), [rawData]);
|
||||||
|
const cuentas = useMemo(() => opcionesUnicas('cuenta_proyecto'), [rawData]);
|
||||||
|
const jefes = useMemo(() => opcionesUnicas('jefe_directo'), [rawData]);
|
||||||
|
|
||||||
|
const [filtroPosicion, setFiltroPosicion] = useState('');
|
||||||
|
const [filtroHomologacion, setFiltroHomologacion] = useState('');
|
||||||
|
const [filtroPais, setFiltroPais] = useState('');
|
||||||
|
const [filtroCuenta, setFiltroCuenta] = useState('');
|
||||||
|
const [filtroJefe, setFiltroJefe] = useState('');
|
||||||
|
const [busquedaNombre, setBusquedaNombre] = useState('');
|
||||||
|
|
||||||
|
const [paginaActual, setPaginaActual] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
aplicarFiltros();
|
||||||
|
setPaginaActual(1);
|
||||||
|
}, [filtroPosicion, filtroHomologacion, filtroPais, filtroCuenta, filtroJefe, rawData]);
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('[DashboardTerminacion] Iniciando fetch...');
|
||||||
|
|
||||||
|
const { data, error: fetchError } = await supabaseData
|
||||||
|
.from('encuesta_terminacion_glm')
|
||||||
|
.select('*')
|
||||||
|
.order('timestamp', { ascending: false });
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error('[DashboardTerminacion] Error de Supabase:', {
|
||||||
|
message: fetchError.message,
|
||||||
|
details: fetchError.details,
|
||||||
|
hint: fetchError.hint,
|
||||||
|
code: fetchError.code,
|
||||||
|
});
|
||||||
|
setError(fetchError.message || 'Error al cargar los datos de encuestas.');
|
||||||
|
} else {
|
||||||
|
console.log('[DashboardTerminacion] Datos recibidos:', data?.length ?? 0, 'registros');
|
||||||
|
setRawData(data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: kpiData, error: kpiError } = await supabaseData
|
||||||
|
.from('encuesta_terminacion_kpi_glm')
|
||||||
|
.select('encuestas_enviadas');
|
||||||
|
|
||||||
|
if (kpiError) {
|
||||||
|
console.error('[DashboardTerminacion] Error al cargar KPI:', kpiError.message);
|
||||||
|
} else {
|
||||||
|
const totalEnviadas = (kpiData ?? []).reduce(
|
||||||
|
(acc, curr) => acc + (Number(curr.encuestas_enviadas) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
setEncuestasEnviadas(totalEnviadas);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const aplicarFiltros = () => {
|
||||||
|
let result = rawData;
|
||||||
|
if (filtroPosicion) result = result.filter(item => item.posicion === filtroPosicion);
|
||||||
|
if (filtroHomologacion) result = result.filter(item => item.homologacion_ia === filtroHomologacion);
|
||||||
|
if (filtroPais) result = result.filter(item => item.pais === filtroPais);
|
||||||
|
if (filtroCuenta) result = result.filter(item => item.cuenta_proyecto === filtroCuenta);
|
||||||
|
if (filtroJefe) result = result.filter(item => item.jefe_directo === filtroJefe);
|
||||||
|
|
||||||
|
// Ordenar por fecha de más reciente a más vieja
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
const dateA = a.timestamp ? new Date(a.timestamp) : 0;
|
||||||
|
const dateB = b.timestamp ? new Date(b.timestamp) : 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredData(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filasPorPagina = 10;
|
||||||
|
const totalPaginas = Math.ceil(filteredData.length / filasPorPagina) || 1;
|
||||||
|
const indiceUltimoItem = paginaActual * filasPorPagina;
|
||||||
|
const indicePrimerItem = indiceUltimoItem - filasPorPagina;
|
||||||
|
const datosFiltradosTabla = busquedaNombre.trim()
|
||||||
|
? filteredData.filter(item =>
|
||||||
|
(item?.nombre ?? '').toString().toLowerCase().includes(busquedaNombre.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
: filteredData;
|
||||||
|
|
||||||
|
const totalRegistrosTabla = datosFiltradosTabla.length;
|
||||||
|
const datosPaginados = datosFiltradosTabla.slice(indicePrimerItem, indiceUltimoItem);
|
||||||
|
|
||||||
|
const totalRegistros = filteredData.length;
|
||||||
|
const porcentajeAprobacion = encuestasEnviadas > 0
|
||||||
|
? Math.round((totalRegistros / encuestasEnviadas) * 100)
|
||||||
|
: 0;
|
||||||
|
const esVerdadero = (v) => v === true || String(v).toLowerCase().trim() === 'true';
|
||||||
|
const regresarian = filteredData.filter(d => esVerdadero(d.considerar_regresar)).length;
|
||||||
|
const recomendarian = filteredData.filter(d => esVerdadero(d.siento_comodo_recomendando)).length;
|
||||||
|
|
||||||
|
const sentimientoCount = filteredData.reduce((acc, curr) => {
|
||||||
|
const sent = curr.sentimiento_ia || 'neutro';
|
||||||
|
acc[sent] = (acc[sent] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const chartDataSentimiento = Object.keys(sentimientoCount).map(key => ({
|
||||||
|
name: key,
|
||||||
|
value: sentimientoCount[key]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pendientes = Math.max(encuestasEnviadas - totalRegistros, 0);
|
||||||
|
const chartDataAprobacion = [
|
||||||
|
{ name: 'Completadas', value: totalRegistros },
|
||||||
|
{ name: 'Pendientes', value: pendientes },
|
||||||
|
];
|
||||||
|
|
||||||
|
const esFalso = (v) => v === false || String(v).toLowerCase().trim() === 'false';
|
||||||
|
|
||||||
|
const chartDataRegresar = [
|
||||||
|
{ name: 'Sí', value: filteredData.filter(d => esVerdadero(d.considerar_regresar)).length },
|
||||||
|
{ name: 'No', value: filteredData.filter(d => esFalso(d.considerar_regresar)).length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartDataRecomendar = [
|
||||||
|
{ name: 'Sí', value: filteredData.filter(d => esVerdadero(d.siento_comodo_recomendando)).length },
|
||||||
|
{ name: 'No', value: filteredData.filter(d => esFalso(d.siento_comodo_recomendando)).length },
|
||||||
|
];
|
||||||
|
|
||||||
|
const contarPorCampo = (campo) => {
|
||||||
|
const conteo = filteredData.reduce((acc, curr) => {
|
||||||
|
const valor = (curr?.[campo] ?? '').toString().trim() || 'Sin especificar';
|
||||||
|
acc[valor] = (acc[valor] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return Object.keys(conteo)
|
||||||
|
.sort((a, b) => conteo[b] - conteo[a])
|
||||||
|
.map((key, index) => ({
|
||||||
|
name: key,
|
||||||
|
value: conteo[key],
|
||||||
|
fill: CHART_PALETTE[index % CHART_PALETTE.length],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartDataMotivoSalida = contarPorCampo('homologacion_ia');
|
||||||
|
const chartDataPorPais = contarPorCampo('pais');
|
||||||
|
const chartDataPorProyecto = contarPorCampo('cuenta_proyecto');
|
||||||
|
|
||||||
|
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 de Encuestas de Terminación</h1>
|
||||||
|
<p className="text-xs text-acero italic">Métricas analíticas de entrevistas de salida</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* NAVEGACIÓN GLM */}
|
||||||
|
<NavBar />
|
||||||
|
|
||||||
|
{/* FILTROS GLM */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Posició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={filtroPosicion}
|
||||||
|
onChange={(e) => setFiltroPosicion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todas las posiciones</option>
|
||||||
|
{posiciones.map((pos, idx) => (
|
||||||
|
<option key={idx} value={pos}>{pos}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Motivo</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={filtroHomologacion}
|
||||||
|
onChange={(e) => setFiltroHomologacion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos los motivos</option>
|
||||||
|
{homologaciones.map((hom, idx) => (
|
||||||
|
<option key={idx} value={hom}>{hom}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por 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>
|
||||||
|
{paises.map((pais, idx) => (
|
||||||
|
<option key={idx} value={pais}>{pais}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Cuenta/Proyecto</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={filtroCuenta}
|
||||||
|
onChange={(e) => setFiltroCuenta(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todas las cuentas/proyectos</option>
|
||||||
|
{cuentas.map((cuenta, idx) => (
|
||||||
|
<option key={idx} value={cuenta}>{cuenta}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Jefe Directo</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={filtroJefe}
|
||||||
|
onChange={(e) => setFiltroJefe(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todos los jefes directos</option>
|
||||||
|
{jefes.map((jefe, idx) => (
|
||||||
|
<option key={idx} value={jefe}>{jefe}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={() => { setFiltroPosicion(''); setFiltroHomologacion(''); setFiltroPais(''); setFiltroCuenta(''); setFiltroJefe(''); }}
|
||||||
|
className="w-full 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>
|
||||||
|
|
||||||
|
{/* KPIs 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-naranja">
|
||||||
|
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Total Encuestas Enviadas</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-naranja mt-1">{encuestasEnviadas}</p>
|
||||||
|
</div>
|
||||||
|
<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 Encuestas Completadas</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalRegistros}</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">Consideraría Regresar</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-verde-glm mt-1">
|
||||||
|
{totalRegistros > 0 ? Math.round((regresarian / totalRegistros) * 100) : 0}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-acero mt-1">{regresarian} empleados interesados</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">Recomendaría la Empresa</h2>
|
||||||
|
<p className="text-3xl font-extrabold text-azul-medio mt-1">
|
||||||
|
{totalRegistros > 0 ? Math.round((recomendarian / totalRegistros) * 100) : 0}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-acero mt-1">{recomendarian} empleados satisfechos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GRÁFICA GLM */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Sentimiento del Motivo</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartDataSentimiento}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
{chartDataSentimiento.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={GLM_COLORS[entry.name.toLowerCase()] || CHART_PALETTE[index % CHART_PALETTE.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">% de Aprobación</h3>
|
||||||
|
<div className="h-64 relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartDataAprobacion}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
dataKey="value"
|
||||||
|
startAngle={90}
|
||||||
|
endAngle={-270}
|
||||||
|
>
|
||||||
|
<Cell fill="#6CC24A" />
|
||||||
|
<Cell fill="#E2E8F0" />
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none" style={{ top: '-2rem' }}>
|
||||||
|
<span className="text-4xl font-extrabold text-verde-glm">{porcentajeAprobacion}%</span>
|
||||||
|
<span className="text-xs text-acero">{totalRegistros} de {encuestasEnviadas}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GRÁFICAS SÍ / NO */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">¿Regresaría a la organización en el futuro?</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataRegresar} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 13, fill: '#4F758B' }} width={40} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Respuestas" radius={[0, 4, 4, 0]}>
|
||||||
|
{chartDataRegresar.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.name === 'Sí' ? '#6CC24A' : '#FF6A13'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Recomendaría a otra persona para trabajar en la organización</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataRecomendar} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 13, fill: '#4F758B' }} width={40} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Respuestas" radius={[0, 4, 4, 0]}>
|
||||||
|
{chartDataRecomendar.map((entry) => (
|
||||||
|
<Cell key={entry.name} fill={entry.name === 'Sí' ? '#6CC24A' : '#FF6A13'} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* GRÁFICAS DE SALIDA LABORAL */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 mb-8">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Motivo de Salida Laboral</h3>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataMotivoSalida} margin={{ top: 10, right: 20, left: 10, bottom: 22 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartDataMotivoSalida.map((entry, index) => (
|
||||||
|
<Cell key={`motivo-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Salida Laboral Según País</h3>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataPorPais} margin={{ top: 10, right: 20, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartDataPorPais.map((entry, index) => (
|
||||||
|
<Cell key={`pais-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Salida Laboral Según Proyecto</h3>
|
||||||
|
<div className="h-72">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartDataPorProyecto} margin={{ top: 10, right: 20, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
|
||||||
|
{chartDataPorProyecto.map((entry, index) => (
|
||||||
|
<Cell key={`proyecto-${index}`} fill={entry.fill} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TABLA GLM */}
|
||||||
|
<div className="bg-white rounded-xl border border-gris-medio/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<h3 className="text-base font-bold text-azul-glm">Detalle de Registros</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs font-bold text-azul-glm uppercase tracking-wider">Buscar empleado</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={busquedaNombre}
|
||||||
|
onChange={(e) => { setBusquedaNombre(e.target.value); setPaginaActual(1); }}
|
||||||
|
placeholder="Escriba el nombre..."
|
||||||
|
className="w-56 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gris-medio/50 text-sm">
|
||||||
|
<thead className="bg-azul-glm">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Fecha</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Nombre</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">País</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Cliente/Proyecto</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Posición</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Jefe Directo</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">PDV</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Trade Partner</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Motivo</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Sentimiento</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gris-medio/30">
|
||||||
|
{datosPaginados.map((item, idx) => (
|
||||||
|
<tr key={idx} className={`${idx % 2 === 0 ? 'bg-white' : 'bg-gris-claro'} hover:bg-verde-bg/50 transition-colors`}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-acero">
|
||||||
|
{new Date(item.timestamp).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap font-semibold text-gris-oscuro">{item.nombre}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.pais}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.cuenta_proyecto}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.posicion}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.jefe_directo}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.pdv}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.trade_partner}</td>
|
||||||
|
<td className="px-6 py-4 text-gris-oscuro max-w-xs truncate">{item.homologacion_ia}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{(() => {
|
||||||
|
const sent = String(item.sentimiento_ia || '').toLowerCase().trim();
|
||||||
|
let classes = 'bg-gray-100 text-gray-800';
|
||||||
|
if (sent === 'positivo') {
|
||||||
|
classes = 'bg-verde-ok text-green-700';
|
||||||
|
} else if (sent === 'negativo') {
|
||||||
|
classes = 'bg-rojo-suave text-red-600';
|
||||||
|
} else if (sent === 'neutro') {
|
||||||
|
classes = 'bg-amarillo-medio text-yellow-700';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`px-2.5 py-1 inline-flex text-xs leading-5 font-bold rounded-full ${classes}`}>
|
||||||
|
{item.sentimiento_ia}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controles de Pagina */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-4 border-t border-gris-medio/30 bg-gris-claro/50">
|
||||||
|
<div className="text-xs text-acero font-medium">
|
||||||
|
{totalRegistros > 0
|
||||||
|
? `Mostrando registros ${indicePrimerItem + 1} - ${Math.min(indiceUltimoItem, totalRegistros)} de ${totalRegistros}`
|
||||||
|
: 'No hay registros para mostrar'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPaginaActual(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={paginaActual === 1}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Atrás
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gris-oscuro font-bold">
|
||||||
|
Página {paginaActual} de {totalPaginas}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPaginaActual(prev => Math.min(prev + 1, totalPaginas))}
|
||||||
|
disabled={paginaActual === totalPaginas}
|
||||||
|
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { user, loading, signInWithGoogle } = useAuth();
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [signingIn, setSigningIn] = useState(false);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gris-claro">
|
||||||
|
<div className="w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/dashboard/encuesta-terminacion" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSigningIn(true);
|
||||||
|
try {
|
||||||
|
await signInWithGoogle();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al iniciar sesion. Intenta de nuevo.');
|
||||||
|
setSigningIn(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gris-claro px-4">
|
||||||
|
<div className="flex flex-col items-center bg-white p-10 rounded-2xl shadow-sm border border-gris-medio/30 max-w-md w-full">
|
||||||
|
<img
|
||||||
|
src="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png"
|
||||||
|
alt="GomezLee Marketing"
|
||||||
|
className="w-64 mb-10"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={signingIn}
|
||||||
|
className="flex items-center justify-center gap-3 w-full bg-white border-2 border-gris-medio/40 hover:border-azul-glm hover:shadow-md text-gris-oscuro font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||||
|
>
|
||||||
|
{signingIn ? (
|
||||||
|
<div className="w-5 h-5 border-2 border-azul-glm border-t-transparent rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{signingIn ? 'Conectando...' : 'Login with Google'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mt-4 text-sm text-red-600 bg-rojo-suave py-2 px-4 rounded-lg text-center w-full">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 text-xs text-acero text-center leading-relaxed">
|
||||||
|
Solo se permiten correos corporativos<br />
|
||||||
|
<span className="font-semibold text-gris-medio">@gomezleemarketing.com</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,27 @@
|
|||||||
// src/supabaseClient.js
|
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_SERVICE_KEY;
|
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
if (!supabaseUrl || !supabaseKey) {
|
||||||
|
console.error('[Supabase] Faltan variables de entorno:', {
|
||||||
|
url: !!supabaseUrl,
|
||||||
|
key: !!supabaseKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: true,
|
||||||
|
persistSession: true,
|
||||||
|
detectSessionInUrl: true,
|
||||||
|
storageKey: 'glm-dashboard-auth',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
headers: {
|
||||||
|
'x-application-name': 'glm-dashboard',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const supabaseData = supabase;
|
||||||
|
|||||||
Reference in New Issue
Block a user