feat: Dashboard Documentos RRHH

This commit is contained in:
2026-09-17 13:33:06 -04:00
parent 71b70e78ee
commit 446862291f
3 changed files with 244 additions and 0 deletions
+9
View File
@@ -4,6 +4,7 @@ import Dashboard from './screens/DashboardTerminacion';
import DashboardCarnet from './screens/DashboardCarnet';
import DashboardGLMWAY from './screens/DashboardGLMWAY';
import DashboardENPS from './screens/DashboardENPS';
import DashboardRRHHDocs from './screens/DashboardRRHHDocs';
import Login from './screens/Login';
import ProtectedRoute from './components/ProtectedRoute';
@@ -44,6 +45,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/dashboard/rrhh-docs"
element={
<ProtectedRoute>
<DashboardRRHHDocs />
</ProtectedRoute>
}
/>
<Route path="/dashboard/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
<Route path="/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
<Route
+6
View File
@@ -35,6 +35,12 @@ export default function NavBar() {
>
eNPS
</NavLink>
<NavLink
to="/dashboard/rrhh-docs"
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
>
Documentos RRHH
</NavLink>
</nav>
);
+229
View File
@@ -0,0 +1,229 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import NavBar from '../components/NavBar';
import { supabaseData } from '../services/supabaseClient';
const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#6B8FA3', '#3E6B8A', '#8FBF5A'];
const MONTHS = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
function getDateParts(value) {
const date = value ? new Date(value) : null;
if (!date || Number.isNaN(date.getTime())) return null;
return { year: date.getFullYear(), month: date.getMonth() + 1 };
}
function formatDuration(seconds) {
const totalSeconds = Math.max(0, Math.round(seconds));
const minutes = Math.floor(totalSeconds / 60);
return `${minutes}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
export default function DashboardRRHHDocs() {
const [rawData, setRawData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filtroPais, setFiltroPais] = useState('');
const [filtroAnio, setFiltroAnio] = useState('');
const [filtroMes, setFiltroMes] = useState('');
const fetchData = async () => {
setLoading(true);
setError(null);
const { data, error: fetchError } = await supabaseData
.from('docassist_kpi')
.select('id, nombre_archivo, pais, tiempo_generacion, cantidad_archivos_generados_en_ejecucion, created_at')
.order('created_at', { ascending: false });
if (fetchError) {
console.error('[DashboardRRHHDocs] Error de Supabase:', fetchError);
setError(fetchError.message || 'Error al cargar los datos de documentos RRHH.');
} else {
setRawData(data ?? []);
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const paises = useMemo(() => [
...new Set(rawData
.map(item => (item.pais ?? '').toString().trim())
.filter(Boolean)),
].sort((a, b) => a.localeCompare(b, 'es')), [rawData]);
const anios = useMemo(() => [
...new Set(rawData
.map(item => getDateParts(item.created_at)?.year)
.filter(Boolean)),
].sort((a, b) => b - a), [rawData]);
const meses = useMemo(() => [
...new Set(rawData
.map(item => getDateParts(item.created_at)?.month)
.filter(Boolean)),
].sort((a, b) => a - b), [rawData]);
const datosFiltrados = useMemo(() => rawData.filter(item => {
const dateParts = getDateParts(item.created_at);
const pais = (item.pais ?? '').toString().trim();
return (!filtroPais || pais === filtroPais)
&& (!filtroAnio || dateParts?.year === Number(filtroAnio))
&& (!filtroMes || dateParts?.month === Number(filtroMes));
}), [rawData, filtroPais, filtroAnio, filtroMes]);
const chartData = useMemo(() => {
const totals = datosFiltrados.reduce((acc, item) => {
const pais = (item.pais ?? '').toString().trim() || 'Sin país';
const cantidad = Number(item.cantidad_archivos_generados_en_ejecucion);
acc[pais] = (acc[pais] || 0) + (Number.isFinite(cantidad) ? cantidad : 0);
return acc;
}, {});
return Object.entries(totals)
.map(([name, value], index) => ({ name, value, fill: CHART_PALETTE[index % CHART_PALETTE.length] }))
.sort((a, b) => b.value - a.value);
}, [datosFiltrados]);
const tiemposPromedio = useMemo(() => {
const groups = datosFiltrados.reduce((acc, item) => {
const cantidad = Number(item.cantidad_archivos_generados_en_ejecucion);
const tiempo = Number(item.tiempo_generacion);
if (!Number.isInteger(cantidad) || cantidad < 1 || !Number.isFinite(tiempo)) return acc;
if (!acc[cantidad]) acc[cantidad] = { total: 0, ejecuciones: 0 };
acc[cantidad].total += tiempo;
acc[cantidad].ejecuciones += 1;
return acc;
}, {});
return Object.entries(groups)
.map(([cantidad, values]) => ({
cantidad: Number(cantidad),
promedio: values.total / values.ejecuciones,
ejecuciones: values.ejecuciones,
}))
.sort((a, b) => a.cantidad - b.cantidad);
}, [datosFiltrados]);
const totalArchivos = chartData.reduce((total, item) => total + item.value, 0);
const hayFiltrosActivos = filtroPais || filtroAnio || filtroMes;
const limpiarFiltros = () => {
setFiltroPais('');
setFiltroAnio('');
setFiltroMes('');
};
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">
<h2 className="text-xl font-bold text-red-700 mb-2">Error al cargar datos</h2>
<p className="text-sm text-gray-600 mb-6">{error}</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">Documentos RRHH</h1>
<p className="text-xs text-acero italic">Generación de documentos por país y tiempo de proceso</p>
</div>
</header>
<NavBar />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
<div>
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">País</label>
<select value={filtroPais} onChange={(event) => setFiltroPais(event.target.value)} 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">
<option value="">Todos los países</option>
{paises.map(pais => <option key={pais} value={pais}>{pais}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Año</label>
<select value={filtroAnio} onChange={(event) => setFiltroAnio(event.target.value)} 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">
<option value="">Todos los años</option>
{anios.map(anio => <option key={anio} value={anio}>{anio}</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={(event) => setFiltroMes(event.target.value)} 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">
<option value="">Todos los meses</option>
{meses.map(mes => <option key={mes} value={mes}>{MONTHS[mes - 1]}</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>
<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 de archivos creados</h2>
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalArchivos}</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">Países con actividad</h2>
<p className="text-3xl font-extrabold text-verde-glm mt-1">{chartData.length}</p>
</div>
</div>
<section className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
<h2 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Tiempo promedio de generación de archivos</h2>
{tiemposPromedio.length === 0 ? <p className="text-center text-acero py-6">No hay tiempos de generación válidos para los filtros seleccionados.</p> : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{tiemposPromedio.map(({ cantidad, promedio, ejecuciones }) => (
<div key={cantidad} className="p-5 rounded-xl bg-verde-bg border border-verde-glm/30">
<p className="text-xs font-bold text-azul-glm uppercase tracking-wider">{cantidad} {cantidad === 1 ? 'archivo' : 'archivos'}</p>
<p className="text-3xl font-extrabold text-azul-glm mt-2">{formatDuration(promedio)}</p>
<p className="text-xs text-acero mt-1">Promedio de {ejecuciones} {ejecuciones === 1 ? 'ejecución' : 'ejecuciones'}</p>
</div>
))}
</div>
)}
</section>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
<h2 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Archivos creados por país</h2>
<div className="h-80">
{chartData.length === 0 ? <p className="text-center text-acero pt-24">No hay datos para los filtros seleccionados.</p> : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} 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 formatter={(value) => [value, 'Archivos creados']} />
<Bar dataKey="value" name="Archivos creados" radius={[4, 4, 0, 0]}>
{chartData.map(item => <Cell key={item.name} fill={item.fill} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
);
}