feat: Nuevos filtros para dashboard de carnet
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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';
|
||||
@@ -9,17 +9,29 @@ export default function DashboardCarnet() {
|
||||
const [rawData, setRawData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [clientes, setClientes] = useState([]);
|
||||
const [filtroCliente, setFiltroCliente] = useState('');
|
||||
|
||||
const [filtroDepartamento, setFiltroDepartamento] = useState('');
|
||||
const [filtroPosicion, setFiltroPosicion] = useState('');
|
||||
const [filtroPais, setFiltroPais] = useState('');
|
||||
const [filtroDivision, setFiltroDivision] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const uniqueClientes = [...new Set(rawData.map(item => item.cliente_proyecto).filter(Boolean))];
|
||||
setClientes(uniqueClientes);
|
||||
}, [rawData]);
|
||||
// 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);
|
||||
@@ -46,21 +58,34 @@ export default function DashboardCarnet() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const datosFiltrados = filtroCliente
|
||||
? rawData.filter(item => item.cliente_proyecto === filtroCliente)
|
||||
: rawData;
|
||||
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 conteoPorCliente = rawData.reduce((acc, curr) => {
|
||||
const cliente = curr.cliente_proyecto || 'Sin cliente';
|
||||
acc[cliente] = (acc[cliente] || 0) + 1;
|
||||
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 chartDataCliente = Object.keys(conteoPorCliente).map((cliente, index) => ({
|
||||
name: cliente,
|
||||
value: conteoPorCliente[cliente],
|
||||
const chartDataDivision = Object.keys(conteoPorDivision).map((division, index) => ({
|
||||
name: division,
|
||||
value: conteoPorDivision[division],
|
||||
fill: CHART_PALETTE[index % CHART_PALETTE.length]
|
||||
}));
|
||||
|
||||
@@ -102,34 +127,74 @@ export default function DashboardCarnet() {
|
||||
</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 cliente / proyecto</p>
|
||||
<p className="text-xs text-acero italic">Conteo de carnets creados por división</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* NAVEGACIÓN GLM */}
|
||||
<NavBar />
|
||||
|
||||
{/* FILTRO 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">
|
||||
{/* 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 Cliente / Proyecto</label>
|
||||
<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={filtroCliente}
|
||||
onChange={(e) => setFiltroCliente(e.target.value)}
|
||||
value={filtroDepartamento}
|
||||
onChange={(e) => setFiltroDepartamento(e.target.value)}
|
||||
>
|
||||
<option value="">Todos los clientes / proyectos</option>
|
||||
{clientes.map((cliente, idx) => (
|
||||
<option key={idx} value={cliente}>{cliente}</option>
|
||||
<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={() => setFiltroCliente('')}
|
||||
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"
|
||||
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 Filtro
|
||||
Limpiar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,18 +206,18 @@ export default function DashboardCarnet() {
|
||||
<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">Clientes / Proyectos</h2>
|
||||
<p className="text-3xl font-extrabold text-verde-glm mt-1">{clientes.length}</p>
|
||||
<p className="text-xs text-acero mt-1">distintos en el histórico</p>
|
||||
<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 Cliente / Proyecto</h3>
|
||||
<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={chartDataCliente} margin={{ top: 10, right: 20, left: 20, bottom: 60 }}>
|
||||
<BarChart data={chartDataDivision} margin={{ top: 10, right: 20, left: 20, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
@@ -166,7 +231,7 @@ export default function DashboardCarnet() {
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="value" name="Carnets" radius={[4, 4, 0, 0]}>
|
||||
{chartDataCliente.map((entry, index) => (
|
||||
{chartDataDivision.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
@@ -4,9 +4,14 @@ 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 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);
|
||||
|
||||
@@ -47,22 +52,21 @@ function GaugeChart({ value }) {
|
||||
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">
|
||||
<svg width="240" height="140" viewBox="0 0 240 140">
|
||||
{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 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, -90 + s * (180 / totalSegments));
|
||||
const a2 = Math.min(endAngle, -90 + (s + 1) * (180 / totalSegments));
|
||||
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));
|
||||
}
|
||||
@@ -75,7 +79,7 @@ function GaugeChart({ value }) {
|
||||
<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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user