feat: implement main dashboard navigation and eNPS survey module with gauge visualization
This commit is contained in:
+19
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Dashboard from './screens/DashboardTerminacion';
|
||||
import DashboardCarnet from './screens/DashboardCarnet';
|
||||
import DashboardGLMWAY from './screens/DashboardGLMWAY';
|
||||
import DashboardENPS from './screens/DashboardENPS';
|
||||
import Login from './screens/Login';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
@@ -26,6 +28,23 @@ export default function App() {
|
||||
</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="*"
|
||||
|
||||
@@ -23,6 +23,18 @@ export default function NavBar() {
|
||||
>
|
||||
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,583 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { supabaseData } from '../services/supabaseClient';
|
||||
import NavBar from '../components/NavBar';
|
||||
|
||||
function GaugeChart({ value }) {
|
||||
const clamped = Math.max(-100, Math.min(100, value || 0));
|
||||
const angle = (clamped + 100) / 200 * 180 - 90;
|
||||
const rad = angle * Math.PI / 180;
|
||||
const cx = 90, cy = 120, r = 90;
|
||||
const nx = cx + r * Math.cos(rad);
|
||||
const ny = cy + r * Math.sin(rad);
|
||||
|
||||
const getColor = (v) => {
|
||||
if (v >= 50) return '#6CC24A';
|
||||
if (v >= 10) return '#F0C75E';
|
||||
if (v >= -10) return '#FF8C42';
|
||||
return '#FF6A13';
|
||||
};
|
||||
const color = getColor(clamped);
|
||||
|
||||
const getLabel = (v) => {
|
||||
if (v >= 50) return 'Excelente';
|
||||
if (v >= 10) return 'Bueno';
|
||||
if (v >= -10) return 'Regular';
|
||||
return 'Crítico';
|
||||
};
|
||||
|
||||
const categories = [
|
||||
{ from: -100, to: -10, label: 'Crítico', color: '#FF6A13' },
|
||||
{ from: -10, to: 10, label: 'Regular', color: '#FF8C42' },
|
||||
{ from: 10, to: 50, label: 'Bueno', color: '#F0C75E' },
|
||||
{ from: 50, to: 100, label: 'Excelente', color: '#6CC24A' },
|
||||
];
|
||||
|
||||
function arcPath(sAngle, eAngle, outerR, innerR) {
|
||||
const sRad = sAngle * Math.PI / 180;
|
||||
const eRad = eAngle * Math.PI / 180;
|
||||
const x1 = cx + outerR * Math.cos(sRad);
|
||||
const y1 = cy + outerR * Math.sin(sRad);
|
||||
const x2 = cx + outerR * Math.cos(eRad);
|
||||
const y2 = cy + outerR * Math.sin(eRad);
|
||||
const x3 = cx + innerR * Math.cos(eRad);
|
||||
const y3 = cy + innerR * Math.sin(eRad);
|
||||
const x4 = cx + innerR * Math.cos(sRad);
|
||||
const y4 = cy + innerR * Math.sin(sRad);
|
||||
const large = (eAngle - sAngle) > 180 ? 1 : 0;
|
||||
return `M ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4} Z`;
|
||||
}
|
||||
|
||||
const bandSize = 12;
|
||||
const innerR = r - bandSize;
|
||||
const totalSegments = 40;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<svg width="180" height="240" viewBox="0 0 180 240">
|
||||
{categories.map((cat, i) => {
|
||||
const startAngle = (cat.from + 100) / 200 * 180 - 90;
|
||||
const endAngle = (cat.to + 100) / 200 * 180 - 90;
|
||||
const segStart = Math.floor((startAngle + 90) / (180 / totalSegments));
|
||||
const segEnd = Math.ceil((endAngle + 90) / (180 / totalSegments));
|
||||
const segments = [];
|
||||
for (let s = segStart; s <= segEnd; s++) {
|
||||
const a1 = Math.max(startAngle, -90 + s * (180 / totalSegments));
|
||||
const a2 = Math.min(endAngle, -90 + (s + 1) * (180 / totalSegments));
|
||||
if (a2 > a1) {
|
||||
segments.push(arcPath(a1, a2, r, innerR));
|
||||
}
|
||||
}
|
||||
return segments.map((d, idx) => (
|
||||
<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-4">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -209,7 +209,7 @@ export default function Dashboard() {
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user