feat: Dashboard Documentos RRHH
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user