feat: Nuevos filtros para dashboard de carnet
This commit is contained in:
+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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { supabaseData } from '../services/supabaseClient';
|
import { supabaseData } from '../services/supabaseClient';
|
||||||
import { BarChart, Bar, Cell, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts';
|
import { BarChart, Bar, Cell, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||||
import NavBar from '../components/NavBar';
|
import NavBar from '../components/NavBar';
|
||||||
@@ -9,17 +9,29 @@ export default function DashboardCarnet() {
|
|||||||
const [rawData, setRawData] = useState([]);
|
const [rawData, setRawData] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
// Helper para obtener valores únicos ordenados de una columna (obtiene todos los valores para la lista del filtro)
|
||||||
const uniqueClientes = [...new Set(rawData.map(item => item.cliente_proyecto).filter(Boolean))];
|
const opcionesUnicas = (campo) => [
|
||||||
setClientes(uniqueClientes);
|
...new Set(
|
||||||
}, [rawData]);
|
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 () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -46,21 +58,34 @@ export default function DashboardCarnet() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const datosFiltrados = filtroCliente
|
const datosFiltrados = rawData.filter(item => {
|
||||||
? rawData.filter(item => item.cliente_proyecto === filtroCliente)
|
const matchDepartamento = !filtroDepartamento || (item?.departamento ?? '').toString().trim() === filtroDepartamento;
|
||||||
: rawData;
|
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 totalCarnets = datosFiltrados.length;
|
||||||
|
|
||||||
const conteoPorCliente = rawData.reduce((acc, curr) => {
|
const limpiarFiltros = () => {
|
||||||
const cliente = curr.cliente_proyecto || 'Sin cliente';
|
setFiltroDepartamento('');
|
||||||
acc[cliente] = (acc[cliente] || 0) + 1;
|
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;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const chartDataCliente = Object.keys(conteoPorCliente).map((cliente, index) => ({
|
const chartDataDivision = Object.keys(conteoPorDivision).map((division, index) => ({
|
||||||
name: cliente,
|
name: division,
|
||||||
value: conteoPorCliente[cliente],
|
value: conteoPorDivision[division],
|
||||||
fill: CHART_PALETTE[index % CHART_PALETTE.length]
|
fill: CHART_PALETTE[index % CHART_PALETTE.length]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -102,34 +127,74 @@ export default function DashboardCarnet() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-center sm:text-left flex-1">
|
<div className="text-center sm:text-left flex-1">
|
||||||
<h1 className="text-2xl font-bold text-azul-glm tracking-tight">Dashboard de Carnets</h1>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* NAVEGACIÓN GLM */}
|
{/* NAVEGACIÓN GLM */}
|
||||||
<NavBar />
|
<NavBar />
|
||||||
|
|
||||||
{/* FILTRO GLM */}
|
{/* 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="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">
|
<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
|
<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"
|
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}
|
value={filtroDepartamento}
|
||||||
onChange={(e) => setFiltroCliente(e.target.value)}
|
onChange={(e) => setFiltroDepartamento(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">Todos los clientes / proyectos</option>
|
<option value="">Todos los departamentos</option>
|
||||||
{clientes.map((cliente, idx) => (
|
{departamentos.map((dep, idx) => (
|
||||||
<option key={idx} value={cliente}>{cliente}</option>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => setFiltroCliente('')}
|
onClick={limpiarFiltros}
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,18 +206,18 @@ export default function DashboardCarnet() {
|
|||||||
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalCarnets}</p>
|
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalCarnets}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-verde-glm">
|
<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>
|
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Divisiones</h2>
|
||||||
<p className="text-3xl font-extrabold text-verde-glm mt-1">{clientes.length}</p>
|
<p className="text-3xl font-extrabold text-verde-glm mt-1">{divisiones.length}</p>
|
||||||
<p className="text-xs text-acero mt-1">distintos en el histórico</p>
|
<p className="text-xs text-acero mt-1">distintas en el histórico</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GRÁFICA GLM */}
|
{/* GRÁFICA GLM */}
|
||||||
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
|
<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">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
@@ -166,7 +231,7 @@ export default function DashboardCarnet() {
|
|||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey="value" name="Carnets" radius={[4, 4, 0, 0]}>
|
<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} />
|
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ import NavBar from '../components/NavBar';
|
|||||||
|
|
||||||
function GaugeChart({ value }) {
|
function GaugeChart({ value }) {
|
||||||
const clamped = Math.max(-100, Math.min(100, value || 0));
|
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 = 120, cy = 100, r = 85;
|
||||||
const cx = 90, cy = 120, r = 90;
|
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 nx = cx + r * Math.cos(rad);
|
||||||
const ny = cy + r * Math.sin(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`;
|
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;
|
const totalSegments = 40;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<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) => {
|
{categories.map((cat, i) => {
|
||||||
const startAngle = (cat.from + 100) / 200 * 180 - 90;
|
const startAngle = toAngle(cat.from);
|
||||||
const endAngle = (cat.to + 100) / 200 * 180 - 90;
|
const endAngle = toAngle(cat.to);
|
||||||
const segStart = Math.floor((startAngle + 90) / (180 / totalSegments));
|
const segSize = 180 / totalSegments;
|
||||||
const segEnd = Math.ceil((endAngle + 90) / (180 / totalSegments));
|
const segStart = Math.floor(startAngle / segSize);
|
||||||
|
const segEnd = Math.ceil(endAngle / segSize);
|
||||||
const segments = [];
|
const segments = [];
|
||||||
for (let s = segStart; s <= segEnd; s++) {
|
for (let s = segStart; s <= segEnd; s++) {
|
||||||
const a1 = Math.max(startAngle, -90 + s * (180 / totalSegments));
|
const a1 = Math.max(startAngle, s * segSize);
|
||||||
const a2 = Math.min(endAngle, -90 + (s + 1) * (180 / totalSegments));
|
const a2 = Math.min(endAngle, (s + 1) * segSize);
|
||||||
if (a2 > a1) {
|
if (a2 > a1) {
|
||||||
segments.push(arcPath(a1, a2, r, innerR));
|
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" />
|
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#1E293B" strokeWidth="3" strokeLinecap="round" />
|
||||||
<circle cx={cx} cy={cy} r="6" fill="#1E293B" />
|
<circle cx={cx} cy={cy} r="6" fill="#1E293B" />
|
||||||
</svg>
|
</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-4xl font-extrabold" style={{ color }}>{clamped}</span>
|
||||||
<span className="text-lg font-bold text-gris-oscuro ml-1">eNPS</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>
|
<p className="text-sm font-semibold text-acero mt-1">{getLabel(clamped)}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user