Preparación Legislativas Nacionales 2025
This commit is contained in:
@@ -1,168 +0,0 @@
|
||||
/* src/components/BancasWidget.css
|
||||
|
||||
/* Contenedor principal del widget */
|
||||
.bancas-widget-container {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
gap: 1rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
/* Cabecera */
|
||||
.bancas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.bancas-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Pestañas de Cámara */
|
||||
.chamber-tabs-bancas {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chamber-tabs-bancas button {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
border: none;
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border-bottom: 3px solid transparent;
|
||||
}
|
||||
|
||||
.chamber-tabs-bancas button:disabled {
|
||||
color: #adb5bd;
|
||||
background-color: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chamber-tabs-bancas button:not(:disabled):hover {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.chamber-tabs-bancas button.active {
|
||||
background-color: #fff;
|
||||
color: #007bff;
|
||||
font-weight: 700;
|
||||
border-bottom: 3px solid #007bff;
|
||||
}
|
||||
|
||||
/* Contenedor del contenido principal */
|
||||
.bancas-content-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
/* Un poco más de espacio */
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Contenedor del gráfico de Waffle */
|
||||
.waffle-chart-container {
|
||||
flex: 3;
|
||||
min-width: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- NUEVOS ESTILOS PARA AGRUPAR --- */
|
||||
.waffle-grid-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Apila los bloques de partido verticalmente */
|
||||
gap: 12px;
|
||||
/* Espacio entre los bloques de cada partido */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.partido-bloque {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
/* Espacio entre las celdas de un mismo partido */
|
||||
}
|
||||
|
||||
/* Celdas individuales del Waffle (Bancas) */
|
||||
.waffle-cell {
|
||||
width: 20px;
|
||||
/* Tamaño fijo para las celdas */
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Leyenda de partidos */
|
||||
.leyenda-container {
|
||||
flex: 2;
|
||||
min-width: 200px;
|
||||
border-left: 1px solid #e9ecef;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
/* Estilos de la lista de partidos */
|
||||
.partido-lista-bancas {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.partido-lista-bancas li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.partido-color-box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 3px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
flex-grow: 1;
|
||||
margin-right: 8px;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.partido-bancas {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Mensajes de carga y error */
|
||||
.loading-text,
|
||||
.error-text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
padding: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
// src/components/BancasWidget.tsx (Corregido)
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select'; // --- CAMBIO: Importar react-select ---
|
||||
import { getBancasPorSeccion, getSeccionesElectoralesConCargos } from '../apiService';
|
||||
import type { ProyeccionBancas, MunicipioSimple } from '../types/types';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import './BancasWidget.css';
|
||||
import type { Property } from 'csstype';
|
||||
|
||||
type CamaraType = 'diputados' | 'senadores';
|
||||
|
||||
// --- CAMBIO: Estilos para el nuevo selector ---
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 10 }),
|
||||
};
|
||||
|
||||
const WaffleDisplay = ({ data }: { data: ProyeccionBancas['proyeccion'] }) => {
|
||||
// El componente WaffleDisplay no necesita cambios en su lógica
|
||||
return (
|
||||
<div className="waffle-grid-container">
|
||||
{data.map(partido => (
|
||||
partido.bancas > 0 && (
|
||||
<div
|
||||
key={partido.agrupacionId}
|
||||
className="partido-bloque"
|
||||
data-tooltip-id="banca-tooltip"
|
||||
data-tooltip-content={`${partido.nombreCorto || partido.agrupacionNombre}: ${partido.bancas} bancas`}
|
||||
>
|
||||
{Array.from({ length: partido.bancas }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="waffle-cell"
|
||||
style={{ backgroundColor: partido.color as Property.BackgroundColor || '#cccccc' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BancasWidget = () => {
|
||||
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
|
||||
// --- CAMBIO: Adaptar el estado para react-select ---
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
const [camaraActiva, setCamaraActiva] = useState<CamaraType>('diputados');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSecciones = async () => {
|
||||
try {
|
||||
const seccionesData = await getSeccionesElectoralesConCargos();
|
||||
if (seccionesData && seccionesData.length > 0) {
|
||||
// --- LÓGICA DE ORDENAMIENTO ---
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
|
||||
// Ordenamos el array de datos ANTES de guardarlo en el estado
|
||||
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
|
||||
setSecciones(seccionesData);
|
||||
|
||||
if (!selectedSeccion) {
|
||||
setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error cargando secciones electorales:", err);
|
||||
}
|
||||
};
|
||||
fetchSecciones();
|
||||
}, [selectedSeccion]);
|
||||
|
||||
// --- CAMBIO: Formatear opciones para react-select ---
|
||||
const seccionOptions = useMemo(() =>
|
||||
secciones.map(s => ({ value: s.id, label: s.nombre })),
|
||||
[secciones]);
|
||||
|
||||
const seccionSeleccionada = useMemo(() =>
|
||||
secciones.find(s => s.id === selectedSeccion?.value),
|
||||
[secciones, selectedSeccion]);
|
||||
|
||||
const camarasDisponibles = useMemo(() =>
|
||||
seccionSeleccionada?.camarasDisponibles || [],
|
||||
[seccionSeleccionada]);
|
||||
|
||||
useEffect(() => {
|
||||
if (seccionSeleccionada && camarasDisponibles.length > 0) {
|
||||
if (!camarasDisponibles.includes(camaraActiva)) {
|
||||
setCamaraActiva(camarasDisponibles[0]);
|
||||
}
|
||||
}
|
||||
}, [seccionSeleccionada, camarasDisponibles, camaraActiva]);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error
|
||||
} = useQuery<ProyeccionBancas, Error>({
|
||||
queryKey: ['bancasPorSeccion', selectedSeccion?.value, camaraActiva],
|
||||
queryFn: () => getBancasPorSeccion(selectedSeccion!.value, camaraActiva),
|
||||
enabled: !!selectedSeccion && camarasDisponibles.includes(camaraActiva),
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error.response?.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
|
||||
const getErrorMessage = () => {
|
||||
if (error) {
|
||||
if ((error as any).response?.status === 404) {
|
||||
return `La proyección para ${camaraActiva} en esta sección aún no está disponible.`;
|
||||
}
|
||||
return "No se pudo conectar para obtener los datos.";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const errorMessage = getErrorMessage();
|
||||
|
||||
// --- CAMBIO: Ordenar la leyenda (y por lo tanto el gráfico) de más a menos bancas ---
|
||||
const leyendaData = useMemo(() =>
|
||||
data?.proyeccion
|
||||
.filter(p => p.bancas > 0)
|
||||
.sort((a, b) => b.bancas - a.bancas) // Ordena de mayor a menor
|
||||
|| [],
|
||||
[data]);
|
||||
|
||||
const totalBancasEnJuego = useMemo(() =>
|
||||
data?.proyeccion.reduce((sum, p) => sum + p.bancas, 0) || 0,
|
||||
[data]);
|
||||
|
||||
return (
|
||||
<div className="bancas-widget-container">
|
||||
<div className="bancas-header">
|
||||
<h4>Bancas Proyectadas: {totalBancasEnJuego}</h4>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={secciones.length === 0}
|
||||
placeholder="Seleccionar..."
|
||||
styles={customSelectStyles}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="chamber-tabs-bancas">
|
||||
<button
|
||||
className={camaraActiva === 'diputados' ? 'active' : ''}
|
||||
onClick={() => setCamaraActiva('diputados')}
|
||||
disabled={seccionSeleccionada && !camarasDisponibles.includes('diputados')}
|
||||
>
|
||||
Diputados
|
||||
</button>
|
||||
<button
|
||||
className={camaraActiva === 'senadores' ? 'active' : ''}
|
||||
onClick={() => setCamaraActiva('senadores')}
|
||||
disabled={seccionSeleccionada && !camarasDisponibles.includes('senadores')}
|
||||
>
|
||||
Senadores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bancas-content-container">
|
||||
<div className="waffle-chart-container">
|
||||
{isLoading ? <p className="loading-text">Cargando...</p> :
|
||||
errorMessage ? <p className="error-text">{errorMessage}</p> :
|
||||
totalBancasEnJuego > 0 ? <WaffleDisplay data={leyendaData} /> :
|
||||
<p>No hay bancas proyectadas para mostrar.</p>
|
||||
}
|
||||
</div>
|
||||
<div className="leyenda-container">
|
||||
<ul className="partido-lista-bancas">
|
||||
{leyendaData.map(partido => (
|
||||
<li key={partido.agrupacionId}>
|
||||
<span className="partido-color-box" style={{ backgroundColor: partido.color as Property.BackgroundColor || '#cccccc' }}></span>
|
||||
<span className="partido-nombre">
|
||||
{partido.nombreCorto || partido.agrupacionNombre}
|
||||
</span>
|
||||
<strong className="partido-bancas">{partido.bancas}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip id="banca-tooltip" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
// src/components/ConcejalesPorSeccionWidget.tsx
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css'; // Reutilizamos los estilos del ticker
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
// Estilos personalizados para que el selector se vea bien
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }), // Para que el menú se superponga
|
||||
};
|
||||
|
||||
const CATEGORIA_ID = 7; // ID para Concejales
|
||||
|
||||
export const ConcejalesPorSeccionWidget = () => {
|
||||
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
// useEffect para obtener la lista de secciones una sola vez
|
||||
useEffect(() => {
|
||||
getSeccionesElectorales().then(seccionesData => {
|
||||
if (seccionesData && seccionesData.length > 0) {
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
|
||||
setSecciones(seccionesData);
|
||||
// Establecemos la primera sección de la lista ordenada como la por defecto
|
||||
if (!selectedSeccion) {
|
||||
setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [selectedSeccion]); // Dependencia para asegurar que no se resetee la selección del usuario
|
||||
|
||||
// Transformamos los datos para react-select
|
||||
const seccionOptions = useMemo(() =>
|
||||
secciones.map(s => ({ value: s.id, label: s.nombre })),
|
||||
[secciones]);
|
||||
|
||||
// Query para obtener los resultados de la sección seleccionada
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
const resultados = data?.resultados || [];
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = resultados;
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-concejales-${selectedSeccion?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>CONCEJALES POR SECCIÓN</h3>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={secciones.length === 0}
|
||||
placeholder="Seleccionar sección..."
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
|
||||
{!selectedSeccion && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
// src/components/ConcejalesTickerWidget.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css'; // Reutilizamos los mismos estilos
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const CATEGORIA_ID = 7; // ID para Concejales
|
||||
|
||||
export const ConcejalesTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
// Usamos useMemo para encontrar los datos específicos de Concejales
|
||||
const ConcejalesData = useMemo(() => {
|
||||
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
|
||||
}, [categorias]);
|
||||
|
||||
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
|
||||
if (error || !ConcejalesData) return <div className="ticker-card error"><p>Datos de Concejales no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = ConcejalesData.resultados;
|
||||
if (ConcejalesData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = ConcejalesData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = ConcejalesData.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-Concejales`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = ConcejalesData.resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>RESUMEN DE {ConcejalesData.categoriaNombre}</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas: <strong>{formatPercent(ConcejalesData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
|
||||
<span>Part: <strong>{formatPercent(ConcejalesData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
// src/components/ConcejalesWidget.tsx
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { MunicipioSimple, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }),
|
||||
};
|
||||
|
||||
const CATEGORIA_ID = 7; // ID para Concejales
|
||||
|
||||
export const ConcejalesWidget = () => {
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// 1. Query para la configuración (se había eliminado por error)
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
// 2. Query para la lista de municipios
|
||||
const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({
|
||||
// Usamos una clave genérica porque siempre pedimos la lista completa.
|
||||
queryKey: ['municipios'],
|
||||
// Llamamos a la función sin argumentos para obtener todos los municipios.
|
||||
queryFn: () => getMunicipios(),
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10);
|
||||
|
||||
useEffect(() => {
|
||||
if (municipios.length > 0 && !selectedMunicipio) {
|
||||
const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'LA PLATA');
|
||||
if (laPlata) {
|
||||
setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre });
|
||||
}
|
||||
}
|
||||
}, [municipios, selectedMunicipio]);
|
||||
|
||||
const municipioOptions = useMemo(() =>
|
||||
municipios
|
||||
.map(m => ({ value: m.id, label: m.nombre }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[municipios]);
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosPorMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = resultados || [];
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-concejales-${selectedMunicipio?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card" style={{ gridColumn: '1 / -1' }}>
|
||||
<div className="ticker-header">
|
||||
<h3>CONCEJALES POR MUNICIPIO</h3>
|
||||
<Select
|
||||
options={municipioOptions}
|
||||
value={selectedMunicipio}
|
||||
onChange={(option) => setSelectedMunicipio(option)}
|
||||
isLoading={isLoadingMunicipios}
|
||||
placeholder="Buscar y seleccionar un municipio..."
|
||||
isClearable
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>}
|
||||
{!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
<div className="party-candidate-name">
|
||||
{partido.nombreCandidato}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
/* src/components/CongresoWidget.css */
|
||||
.congreso-container {
|
||||
display: flex;
|
||||
/* Se reduce ligeramente el espacio entre el gráfico y el panel */
|
||||
gap: 1rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif;
|
||||
color: #333333;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.congreso-grafico {
|
||||
/* --- CAMBIO PRINCIPAL: Se aumenta la proporción del gráfico --- */
|
||||
flex: 1 1 65%;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.congreso-grafico svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
animation: fadeIn 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.congreso-summary {
|
||||
/* --- CAMBIO PRINCIPAL: Se reduce la proporción del panel de datos --- */
|
||||
flex: 1 1 35%;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
/* Se reduce el padding para dar aún más espacio al gráfico */
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.congreso-summary h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.4em;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.chamber-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chamber-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: none;
|
||||
background-color: #f8f9fa;
|
||||
color: #6c757d;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.chamber-tabs button:first-child {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.chamber-tabs button:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.chamber-tabs button.active {
|
||||
background-color: var(--primary-accent-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.summary-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.summary-metric strong {
|
||||
font-size: 1.5em;
|
||||
font-weight: 700;
|
||||
color: var(--primary-accent-color);
|
||||
}
|
||||
|
||||
.congreso-summary hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.partido-lista {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.partido-lista li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.partido-color-box {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.partido-bancas {
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* --- Media Query para Responsividad Móvil --- */
|
||||
@media (max-width: 768px) {
|
||||
.congreso-container {
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.congreso-summary {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.seat-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.seat-tooltip img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.seat-tooltip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.seat-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
background-color: white;
|
||||
}
|
||||
.seat-tooltip img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ccc;
|
||||
}
|
||||
.seat-tooltip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#seat-tooltip.react-tooltip {
|
||||
opacity: 1 !important;
|
||||
background-color: white; /* Opcional: asegura un fondo sólido */
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// src/components/CongresoWidget.tsx
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ParliamentLayout } from './ParliamentLayout';
|
||||
import { SenateLayout } from './SenateLayout';
|
||||
import { getComposicionCongreso, getBancadasDetalle } from '../apiService';
|
||||
import type { ComposicionData, BancadaDetalle } from '../apiService';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import './CongresoWidget.css';
|
||||
|
||||
type CamaraType = 'diputados' | 'senadores';
|
||||
const DEFAULT_COLOR = '#808080';
|
||||
|
||||
export const CongresoWidget = () => {
|
||||
const [camaraActiva, setCamaraActiva] = useState<CamaraType>('diputados');
|
||||
|
||||
const { data: composicionData, isLoading: isLoadingComposicion, error: errorComposicion } = useQuery<ComposicionData>({
|
||||
queryKey: ['composicionCongreso'],
|
||||
queryFn: getComposicionCongreso,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const { data: bancadasDetalle = [] } = useQuery<BancadaDetalle[]>({
|
||||
queryKey: ['bancadasDetalle'],
|
||||
queryFn: getBancadasDetalle,
|
||||
enabled: !!composicionData,
|
||||
});
|
||||
|
||||
const datosCamaraActual = composicionData ? composicionData[camaraActiva] : null;
|
||||
|
||||
const esModoOficial = bancadasDetalle.length > 0;
|
||||
|
||||
// --- LÓGICA DE SEATFILLDATA ---
|
||||
const seatFillData = useMemo(() => {
|
||||
if (!datosCamaraActual) return [];
|
||||
|
||||
if (esModoOficial) {
|
||||
// --- MODO OFICIAL ---
|
||||
const camaraId = camaraActiva === 'diputados' ? 0 : 1;
|
||||
const bancadasDeCamara = bancadasDetalle.filter(b => b.camara === camaraId);
|
||||
const colorMap = new Map<string, string>();
|
||||
datosCamaraActual.partidos.forEach(p => { if (p.id && p.color) colorMap.set(p.id, p.color); });
|
||||
|
||||
// 1. Creamos un array del tamaño correcto, lleno de 'null's
|
||||
const size = camaraActiva === 'diputados' ? 92 : 46;
|
||||
const finalSeatData = new Array(size).fill(null);
|
||||
|
||||
// 2. Poblamos el array usando NumeroBanca como índice
|
||||
bancadasDeCamara.forEach(bancada => {
|
||||
// El índice del SVG es NumeroBanca - 1
|
||||
const index = bancada.numeroBanca - 1;
|
||||
if (index >= 0 && index < size) {
|
||||
finalSeatData[index] = {
|
||||
color: bancada.agrupacionPoliticaId ? colorMap.get(bancada.agrupacionPoliticaId) || DEFAULT_COLOR : DEFAULT_COLOR,
|
||||
ocupante: bancada.ocupante
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return finalSeatData;
|
||||
|
||||
} else {
|
||||
// --- MODO PROYECCIÓN ---
|
||||
return datosCamaraActual.partidos.flatMap(party => {
|
||||
const seatColor = party.color || DEFAULT_COLOR;
|
||||
return Array(party.bancasTotales).fill({ color: seatColor, ocupante: null });
|
||||
});
|
||||
}
|
||||
}, [datosCamaraActual, bancadasDetalle, camaraActiva]);
|
||||
|
||||
if (isLoadingComposicion) return <div className="congreso-container loading">Cargando...</div>;
|
||||
if (errorComposicion || !datosCamaraActual) return <div className="congreso-container error">No se pudo cargar la composición.</div>;
|
||||
|
||||
const partidosOrdenados = datosCamaraActual.partidos;
|
||||
|
||||
return (
|
||||
<div className="congreso-container">
|
||||
<div className="congreso-grafico">
|
||||
{camaraActiva === 'diputados' ? (
|
||||
<ParliamentLayout
|
||||
seatData={seatFillData}
|
||||
// Solo pasamos la prop 'presidenteBancada' si NO estamos en modo oficial
|
||||
presidenteBancada={!esModoOficial ? datosCamaraActual.presidenteBancada : undefined}
|
||||
/>
|
||||
) : (
|
||||
<SenateLayout
|
||||
seatData={seatFillData}
|
||||
presidenteBancada={!esModoOficial ? datosCamaraActual.presidenteBancada : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="congreso-summary">
|
||||
<div className="chamber-tabs">
|
||||
<button
|
||||
className={camaraActiva === 'diputados' ? 'active' : ''}
|
||||
onClick={() => setCamaraActiva('diputados')}
|
||||
>
|
||||
Diputados
|
||||
</button>
|
||||
<button
|
||||
className={camaraActiva === 'senadores' ? 'active' : ''}
|
||||
onClick={() => setCamaraActiva('senadores')}
|
||||
>
|
||||
Senadores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3>{datosCamaraActual.camaraNombre}</h3>
|
||||
<div className="summary-metric">
|
||||
<span>Total de Bancas</span>
|
||||
<strong>{datosCamaraActual.totalBancas}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<ul className="partido-lista">
|
||||
{partidosOrdenados.map(partido => (
|
||||
<li key={partido.id}>
|
||||
<span className="partido-color-box" style={{ backgroundColor: partido.color || DEFAULT_COLOR }}></span>
|
||||
<span className="partido-nombre">
|
||||
{partido.nombreCorto || partido.nombre}
|
||||
</span>
|
||||
<strong className="partido-bancas">{partido.bancasTotales}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{/* Es importante que el Tooltip esté fuera del div que se re-renderiza con el cambio de pestaña */}
|
||||
<Tooltip id="seat-tooltip" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
// src/components/DipSenTickerWidget.tsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
export const DipSenTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = useMemo(() => {
|
||||
return parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
}, [configData]);
|
||||
|
||||
if (isLoading) return <div className="ticker-wrapper loading">Cargando resumen...</div>;
|
||||
if (error || !categorias) return <div className="ticker-wrapper error">No hay datos disponibles.</div>;
|
||||
|
||||
const categoriasFiltradas = categorias.filter(c => c.categoriaId !== 7);
|
||||
|
||||
return (
|
||||
<div className="ticker-wrapper">
|
||||
{categoriasFiltradas.map(categoria => {
|
||||
|
||||
let displayResults: ResultadoTicker[] = categoria.resultados;
|
||||
|
||||
if (categoria.resultados.length > cantidadAMostrar) {
|
||||
const topParties = categoria.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = categoria.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-${categoria.categoriaId}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = categoria.resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={categoria.categoriaId} className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>{categoria.categoriaNombre}</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas: <strong>{formatPercent(categoria.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong></span>
|
||||
<span>Part: <strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback
|
||||
src={partido.logoUrl || undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={`Logo de ${partido.nombre}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
// src/components/DiputadosPorSeccionWidget.tsx
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }),
|
||||
};
|
||||
|
||||
const CATEGORIA_ID = 6; // ID para Diputados
|
||||
|
||||
export const DiputadosPorSeccionWidget = () => {
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10);
|
||||
|
||||
// Ahora usamos useQuery para obtener las secciones filtradas
|
||||
const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({
|
||||
queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché
|
||||
queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría
|
||||
});
|
||||
|
||||
// useEffect para establecer la primera sección por defecto
|
||||
useEffect(() => {
|
||||
if (secciones.length > 0 && !selectedSeccion) {
|
||||
// Ordenamos aquí solo para la selección inicial
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
|
||||
setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre });
|
||||
}
|
||||
}, [secciones, selectedSeccion]);
|
||||
|
||||
const seccionOptions = useMemo(() =>
|
||||
secciones
|
||||
.map(s => ({ value: s.id, label: s.nombre }))
|
||||
.sort((a, b) => { // Mantenemos el orden en el dropdown
|
||||
const orden = new Map([
|
||||
['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3],
|
||||
['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7]
|
||||
]);
|
||||
return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99);
|
||||
}),
|
||||
[secciones]);
|
||||
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
const resultados = data?.resultados || [];
|
||||
|
||||
let displayResults: ResultadoTicker[] = resultados;
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-diputados-${selectedSeccion?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>DIPUTADOS POR SECCIÓN</h3>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={isLoadingSecciones}
|
||||
placeholder="Seleccionar sección..."
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
|
||||
{!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
// src/components/DiputadosTickerWidget.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const CATEGORIA_ID = 6; // ID para Diputados
|
||||
|
||||
export const DiputadosTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
// Usamos useMemo para encontrar los datos específicos de Diputados
|
||||
const diputadosData = useMemo(() => {
|
||||
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
|
||||
}, [categorias]);
|
||||
|
||||
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
|
||||
if (error || !diputadosData) return <div className="ticker-card error"><p>Datos de Diputados no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = diputadosData.resultados;
|
||||
if (diputadosData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = diputadosData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = diputadosData.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-diputados`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = diputadosData.resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>RESUMEN DE {diputadosData.categoriaNombre}</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas: <strong>{formatPercent(diputadosData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
|
||||
<span>Part: <strong>{formatPercent(diputadosData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
// src/components/DiputadosWidget.tsx
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { MunicipioSimple, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }),
|
||||
};
|
||||
|
||||
// Constante para la categoría de este widget
|
||||
const CATEGORIA_ID = 6; // Diputados
|
||||
|
||||
export const DiputadosWidget = () => {
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
// Usamos la clave de configuración del Ticker, ya que es para Senadores/Diputados
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({
|
||||
queryKey: ['municipios', CATEGORIA_ID], // Key única para la lista de municipios de diputados
|
||||
queryFn: () => getMunicipios(CATEGORIA_ID), // Pide solo los municipios que votan diputados
|
||||
});
|
||||
|
||||
// useEffect para establecer "LA PLATA" por defecto
|
||||
useEffect(() => {
|
||||
if (municipios.length > 0 && !selectedMunicipio) {
|
||||
const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'LA PLATA');
|
||||
if (laPlata) {
|
||||
setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre });
|
||||
} else if (municipios.length > 0) {
|
||||
// Si no está La Plata, seleccionamos el primero de la lista
|
||||
setSelectedMunicipio({ value: municipios[0].id, label: municipios[0].nombre });
|
||||
}
|
||||
}
|
||||
}, [municipios, selectedMunicipio]);
|
||||
|
||||
const municipioOptions = useMemo(() =>
|
||||
municipios
|
||||
.map(m => ({ value: m.id, label: m.nombre }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[municipios]);
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = resultados || [];
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-diputados-${selectedMunicipio?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>DIPUTADOS POR MUNICIPIO</h3>
|
||||
<Select
|
||||
options={municipioOptions}
|
||||
value={selectedMunicipio}
|
||||
onChange={(option) => setSelectedMunicipio(option)}
|
||||
isLoading={isLoadingMunicipios}
|
||||
placeholder="Buscar municipio..."
|
||||
isClearable
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>}
|
||||
{!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
<div className="party-candidate-name">
|
||||
{partido.nombreCandidato}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,324 +0,0 @@
|
||||
/* src/components/MapaBsAs.css */
|
||||
:root {
|
||||
--primary-accent-color: #0073e6;
|
||||
--background-panel-color: #ffffff;
|
||||
--border-color: #dee2e6;
|
||||
--text-color: #212529;
|
||||
--text-color-muted: #6c757d;
|
||||
--progress-bar-background: #e9ecef;
|
||||
--scrollbar-thumb-color: #ced4da;
|
||||
--scrollbar-track-color: #f1f1f1;
|
||||
--map-background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.mapa-wrapper {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-width: 960px;
|
||||
margin: auto;
|
||||
height: 88vh;
|
||||
min-height: 650px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
flex: 0 0 70%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--map-background-color);
|
||||
}
|
||||
|
||||
.mapa-container .rsm-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* APLICA LA TRANSICIÓN POR DEFECTO A ZOOMABLEGROUP */
|
||||
.mapa-container .rsm-zoomable-group {
|
||||
transition: transform 400ms ease-in-out;
|
||||
}
|
||||
|
||||
/* DESACTIVA LA TRANSICIÓN CUANDO SE ESTÁ ARRASTRANDO */
|
||||
.mapa-container .rsm-zoomable-group.panning {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.rsm-geography {
|
||||
transition: opacity 0.3s ease, transform 0.2s ease, filter 0.2s ease, fill 0.3s ease;
|
||||
cursor: pointer;
|
||||
stroke: #b0b0b0;
|
||||
stroke-width: 0.5px;
|
||||
}
|
||||
|
||||
.rsm-geography:hover {
|
||||
stroke: var(--primary-accent-color);
|
||||
stroke-width: 1.5px;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.rsm-geography.selected {
|
||||
stroke: #333;
|
||||
stroke-width: 2px;
|
||||
filter: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rsm-geography.faded {
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
background-color: var(--background-panel-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.info-panel::-webkit-scrollbar { width: 8px; }
|
||||
.info-panel::-webkit-scrollbar-track { background: var(--scrollbar-track-color); border-radius: 4px; }
|
||||
.info-panel::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-color); border-radius: 4px; border: 2px solid var(--scrollbar-track-color); }
|
||||
.info-panel::-webkit-scrollbar-thumb:hover { background-color: #adb5bd; }
|
||||
|
||||
.info-panel h3 {
|
||||
margin-top: 0;
|
||||
color: var(--primary-accent-color);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
color: var(--text-color-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.reset-button-panel {
|
||||
background: none; border: 1px solid var(--primary-accent-color); color: var(--primary-accent-color); padding: 0.5rem 1rem; border-radius: 5px; cursor: pointer; transition: all 0.2s; margin-bottom: 1rem; align-self: flex-start; font-size: 0.9rem;
|
||||
}
|
||||
.reset-button-panel:hover { background-color: var(--primary-accent-color); color: white; }
|
||||
|
||||
.detalle-placeholder {
|
||||
text-align: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
.detalle-loading, .detalle-error { text-align: center; margin: auto 0; color: var(--text-color-muted); }
|
||||
.detalle-metricas {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.resultados-lista {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.resultados-lista li {
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
.resultado-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
font-weight: 500;
|
||||
color: #343a40;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.partido-votos {
|
||||
font-weight: 400;
|
||||
color: var(--text-color-muted);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background-color: var(--progress-bar-background);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-accent-color);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.spinner { width: 40px; height: 40px; border: 4px solid var(--border-color); border-top-color: var(--primary-accent-color); border-radius: 50%; animation: spin 1s linear infinite; margin: 1rem auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA EL BOTÓN DE "VOLVER" (VISTA DESKTOP) --- */
|
||||
.map-controls button {
|
||||
/* Se elimina el ancho y alto fijos para que el botón se ajuste al texto. */
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Se define un padding para dar espacio interno al texto. */
|
||||
padding: 0.5rem 1rem;
|
||||
/* Se ajusta el tamaño de fuente para el texto. */
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
background-color: #ffffff;
|
||||
color: #333;
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
/* Se eliminan las propiedades de centrado de íconos que ya no son necesarias. */
|
||||
}
|
||||
.map-controls button:hover {
|
||||
background-color: #f8f9fa;
|
||||
border-color: var(--primary-accent-color);
|
||||
color: var(--primary-accent-color);
|
||||
}
|
||||
|
||||
.legend {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.legend h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#root .legend h4 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.legend-color-box { width: 16px; height: 16px; margin-right: 8px; border-radius: 3px; border: 1px solid #ccc; }
|
||||
|
||||
/* --- ESTILOS PARA RESPONSIVIDAD MÓVIL --- */
|
||||
@media (max-width: 992px) {
|
||||
.mapa-wrapper {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex-basis: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow-y: visible;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA EL BOTÓN DE "VOLVER" (VISTA MÓVIL) --- */
|
||||
.map-controls button {
|
||||
/* Se elimina el ancho y alto fijos para que el botón se ajuste al texto. */
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Se ajusta el padding para que sea un buen objetivo táctil (tappable). */
|
||||
padding: 0.6rem 1.2rem;
|
||||
/* Un tamaño de fuente legible en móviles. */
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA EL SELECTOR DE CATEGORÍA --- */
|
||||
.mapa-categoria-selector {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mapa-categoria-combobox {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%230073e6%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.4-12.9z%22%2F%3E%3C%2Fsvg%3E');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
background-size: 0.8em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mapa-categoria-combobox:hover {
|
||||
border-color: var(--primary-accent-color);
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.mapa-categoria-combobox:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 115, 230, 0.25);
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA SECCIONES NO CLICLEABLES --- */
|
||||
.rsm-geography.no-results {
|
||||
pointer-events: none; /* Ignora todos los eventos del ratón (click, hover, etc.) */
|
||||
cursor: default; /* Muestra el cursor por defecto en lugar de la mano */
|
||||
}
|
||||
|
||||
/* Opcional pero recomendado: modificar la regla :hover para que no afecte a las secciones no clicleables */
|
||||
.rsm-geography:not(.no-results):hover {
|
||||
stroke: var(--primary-accent-color);
|
||||
stroke-width: 1.5px;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
// src/components/MapaBsAs.tsx
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { feature } from 'topojson-client';
|
||||
import type { Feature, Geometry } from 'geojson';
|
||||
import { geoCentroid } from 'd3-geo';
|
||||
import { API_BASE_URL, assetBaseUrl } from '../apiService';
|
||||
import './MapaBsAs.css';
|
||||
|
||||
// --- Interfaces y Tipos ---
|
||||
type PointTuple = [number, number];
|
||||
|
||||
interface MapaBsAsProps {
|
||||
focoMunicipio?: string;
|
||||
focoCategoria?: string;
|
||||
}
|
||||
|
||||
interface ResultadoMapa {
|
||||
ambitoId: number;
|
||||
departamentoNombre: string;
|
||||
agrupacionGanadoraId: string;
|
||||
colorGanador: string | null;
|
||||
}
|
||||
|
||||
interface ResultadoDetalladoMunicipio {
|
||||
municipioNombre: string;
|
||||
ultimaActualizacion: string;
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: { id: string; nombre: string; votos: number; porcentaje: number; color: string | null; }[];
|
||||
votosAdicionales: { enBlanco: number; nulos: number; recurridos: number };
|
||||
}
|
||||
|
||||
interface Agrupacion {
|
||||
id: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
interface Categoria { id: number; nombre: string; }
|
||||
interface PartidoProperties { departamento: string; }
|
||||
type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string };
|
||||
|
||||
// --- Constantes ---
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -1000], [1100, 800]];
|
||||
const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM };
|
||||
const DEFAULT_MAP_COLOR = '#E0E0E0';
|
||||
|
||||
const CATEGORIAS: Categoria[] = [
|
||||
{ id: 6, nombre: 'Diputados' },
|
||||
{ id: 5, nombre: 'Senadores' },
|
||||
{ id: 7, nombre: 'Concejales' }
|
||||
];
|
||||
|
||||
// --- Helper de Normalización ---
|
||||
const normalizarTexto = (texto: string = ''): string => {
|
||||
return texto
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.normalize("NFD") // Separa los acentos de las letras
|
||||
.replace(/[\u0300-\u036f]/g, ""); // Elimina los acentos
|
||||
};
|
||||
|
||||
// --- Componente Principal ---
|
||||
const MapaBsAs = ({ focoMunicipio, focoCategoria }: MapaBsAsProps) => {
|
||||
// --- LÓGICA DE ESTADO SIMPLIFICADA ---
|
||||
const categoriaInicial = useMemo(() => {
|
||||
const catNorm = focoCategoria?.toLowerCase();
|
||||
if (catNorm === 'senadores') return 5;
|
||||
if (catNorm === 'concejales') return 7;
|
||||
return 6;
|
||||
}, [focoCategoria]);
|
||||
|
||||
const [position, setPosition] = useState(INITIAL_POSITION);
|
||||
const [selectedAmbitoId, setSelectedAmbitoId] = useState<number | null>(null);
|
||||
const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(categoriaInicial);
|
||||
const [tooltipContent, setTooltipContent] = useState('');
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
// Sincroniza el estado si la prop cambia. Esto es para cuando el widget ya está montado
|
||||
// y recibe nuevas props (no ocurrirá en tu caso actual, pero es buena práctica).
|
||||
useEffect(() => {
|
||||
setSelectedCategoriaId(categoriaInicial);
|
||||
}, [categoriaInicial]);
|
||||
|
||||
// --- QUERIES ---
|
||||
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({
|
||||
queryKey: ['mapaResultadosPorMunicipio', selectedCategoriaId],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-municipio?categoriaId=${selectedCategoriaId}`)).data,
|
||||
});
|
||||
|
||||
const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({
|
||||
queryKey: ['mapaGeoData'],
|
||||
queryFn: async () => (await axios.get(`${assetBaseUrl}/partidos-bsas.topojson`)).data,
|
||||
});
|
||||
|
||||
const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
|
||||
queryKey: ['catalogoAgrupaciones'],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data,
|
||||
});
|
||||
|
||||
const { nombresAgrupaciones, resultadosPorDepartamento } = useMemo(() => {
|
||||
const nombresMap = new Map<string, string>();
|
||||
const resultadosMap = new Map<string, ResultadoMapa>();
|
||||
if (agrupacionesData) {
|
||||
agrupacionesData.forEach((a) => nombresMap.set(a.id, a.nombre));
|
||||
}
|
||||
if (resultadosData) {
|
||||
resultadosData.forEach(r => {
|
||||
if (r.departamentoNombre) {
|
||||
resultadosMap.set(normalizarTexto(r.departamentoNombre), r);
|
||||
}
|
||||
});
|
||||
}
|
||||
return { nombresAgrupaciones: nombresMap, resultadosPorDepartamento: resultadosMap };
|
||||
}, [agrupacionesData, resultadosData]);
|
||||
|
||||
const isLoading = isLoadingResultados || isLoadingAgrupaciones || isLoadingGeo;
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setSelectedAmbitoId(null);
|
||||
setPosition(INITIAL_POSITION);
|
||||
}, []);
|
||||
|
||||
// --- LÓGICA DE CLIC Y FOCO ---
|
||||
const handleGeographyClick = useCallback((geo: PartidoGeography) => {
|
||||
const departamentoNombreNormalizado = normalizarTexto(geo.properties.departamento);
|
||||
const resultado = resultadosPorDepartamento.get(departamentoNombreNormalizado);
|
||||
if (!resultado) return;
|
||||
|
||||
if (selectedAmbitoId === resultado.ambitoId) {
|
||||
handleReset();
|
||||
} else {
|
||||
const centroid = geoCentroid(geo) as PointTuple;
|
||||
setPosition({ center: centroid, zoom: 5 });
|
||||
setSelectedAmbitoId(resultado.ambitoId);
|
||||
}
|
||||
}, [selectedAmbitoId, handleReset, resultadosPorDepartamento]);
|
||||
|
||||
// --- useEffect DE INICIALIZACIÓN ---
|
||||
useEffect(() => {
|
||||
if (isLoading || !focoMunicipio || selectedAmbitoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometries = geoData?.objects?.['departamentos-buenos_aires']?.geometries;
|
||||
if (!geometries) return;
|
||||
|
||||
const nombreFocoNormalizado = normalizarTexto(focoMunicipio);
|
||||
const geoTargetTopo = geometries.find(
|
||||
(g: any) => normalizarTexto(g.properties.departamento) === nombreFocoNormalizado
|
||||
);
|
||||
|
||||
if (geoTargetTopo) {
|
||||
if (resultadosPorDepartamento.has(nombreFocoNormalizado)) {
|
||||
const geoTargetGeoJSON = feature(geoData, geoTargetTopo);
|
||||
handleGeographyClick(geoTargetGeoJSON as unknown as PartidoGeography);
|
||||
}
|
||||
}
|
||||
// Deshabilitamos la regla para que solo se ejecute cuando isLoading cambia a false.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoriaInicial !== selectedCategoriaId) {
|
||||
setSelectedCategoriaId(categoriaInicial);
|
||||
handleReset();
|
||||
}
|
||||
}, [categoriaInicial, selectedCategoriaId, handleReset]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleReset]);
|
||||
|
||||
const renderGeography = useCallback((geo: PartidoGeography, isSelectedGeo: boolean = false) => {
|
||||
const departamentoNombreNormalizado = normalizarTexto(geo.properties.departamento);
|
||||
const resultado = resultadosPorDepartamento.get(departamentoNombreNormalizado);
|
||||
const isClickable = !!resultado;
|
||||
const isSelected = isSelectedGeo || (selectedAmbitoId !== null && selectedAmbitoId === resultado?.ambitoId);
|
||||
const isFaded = !isSelectedGeo && selectedAmbitoId !== null && !isSelected;
|
||||
|
||||
const nombreAgrupacionGanadora = resultado
|
||||
? nombresAgrupaciones.get(resultado.agrupacionGanadoraId)
|
||||
: 'Sin datos';
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey + (isSelectedGeo ? '-selected' : '')}
|
||||
geography={geo}
|
||||
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`}
|
||||
fill={resultado?.colorGanador || DEFAULT_MAP_COLOR}
|
||||
onClick={isClickable ? () => handleGeographyClick(geo) : undefined}
|
||||
onMouseEnter={() => {
|
||||
if (isClickable) {
|
||||
setTooltipContent(`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setTooltipContent("")}
|
||||
/>
|
||||
);
|
||||
}, [resultadosPorDepartamento, selectedAmbitoId, nombresAgrupaciones, handleGeographyClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !focoMunicipio || selectedAmbitoId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geometries = geoData?.objects?.['departamentos-buenos_aires']?.geometries;
|
||||
if (!geometries) return;
|
||||
|
||||
const nombreFocoNormalizado = normalizarTexto(focoMunicipio);
|
||||
const geoTargetTopo = geometries.find(
|
||||
(g: any) => normalizarTexto(g.properties.departamento) === nombreFocoNormalizado
|
||||
);
|
||||
|
||||
if (geoTargetTopo && resultadosPorDepartamento.has(nombreFocoNormalizado)) {
|
||||
const geoTargetGeoJSON = feature(geoData, geoTargetTopo);
|
||||
handleGeographyClick(geoTargetGeoJSON as unknown as PartidoGeography);
|
||||
}
|
||||
}, [isLoading, focoMunicipio, selectedCategoriaId]);
|
||||
|
||||
const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => {
|
||||
if (newPosition.zoom <= MIN_ZOOM) {
|
||||
if (position.zoom > MIN_ZOOM || selectedAmbitoId !== null) {
|
||||
handleReset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (newPosition.zoom < position.zoom && selectedAmbitoId !== null) {
|
||||
setSelectedAmbitoId(null);
|
||||
}
|
||||
setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom });
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (position.zoom < MAX_ZOOM) {
|
||||
setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleReset]);
|
||||
|
||||
return (
|
||||
<div className="mapa-wrapper">
|
||||
<div className="mapa-container">
|
||||
{isLoading ? <div className="spinner"></div> : (
|
||||
<ComposableMap
|
||||
key={selectedCategoriaId}
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }}
|
||||
className="rsm-svg"
|
||||
data-tooltip-id="partido-tooltip"
|
||||
>
|
||||
<ZoomableGroup
|
||||
center={position.center}
|
||||
zoom={position.zoom}
|
||||
className={isPanning ? 'panning' : ''}
|
||||
onMoveStart={() => setIsPanning(true)}
|
||||
onMoveEnd={(newPosition: { coordinates: PointTuple; zoom: number }) => {
|
||||
setIsPanning(false);
|
||||
handleMoveEnd(newPosition);
|
||||
}}
|
||||
translateExtent={TRANSLATE_EXTENT}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
filterZoomEvent={(e: WheelEvent) => {
|
||||
if (e.deltaY > 0) {
|
||||
handleReset();
|
||||
} else if (e.deltaY < 0) {
|
||||
handleZoomIn();
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
{geoData && (
|
||||
<Geographies geography={geoData}>
|
||||
{({ geographies }: { geographies: PartidoGeography[] }) => {
|
||||
const selectedGeo = selectedAmbitoId
|
||||
? geographies.find(geo => {
|
||||
const resultado = resultadosPorDepartamento.get(normalizarTexto(geo.properties.departamento));
|
||||
return resultado?.ambitoId === selectedAmbitoId;
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{geographies.map(geo => (!selectedGeo || geo.rsmKey !== selectedGeo.rsmKey) ? renderGeography(geo) : null)}
|
||||
{selectedGeo && renderGeography(selectedGeo, true)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Geographies>
|
||||
)}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
)}
|
||||
<Tooltip id="partido-tooltip" content={tooltipContent} />
|
||||
{selectedAmbitoId !== null && <ControlesMapa onReset={handleReset} />}
|
||||
</div>
|
||||
<div className="info-panel">
|
||||
<div className="mapa-categoria-selector">
|
||||
<select
|
||||
className="mapa-categoria-combobox"
|
||||
value={selectedCategoriaId}
|
||||
onChange={(e) => {
|
||||
// --- LÓGICA DE CAMBIO DE CATEGORÍA ---
|
||||
// Limpiamos el foco de municipio al cambiar de categoría
|
||||
setSelectedAmbitoId(null);
|
||||
setPosition(INITIAL_POSITION);
|
||||
setSelectedCategoriaId(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{CATEGORIAS.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} categoriaId={selectedCategoriaId} />
|
||||
<Legend resultados={resultadosPorDepartamento} nombresAgrupaciones={nombresAgrupaciones} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-componentes ---
|
||||
const ControlesMapa = ({ onReset }: { onReset: () => void }) => (
|
||||
|
||||
<div className="map-controls">
|
||||
<button onClick={onReset}>← VOLVER</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetalleMunicipio = ({ ambitoId, onReset, categoriaId }: { ambitoId: number | null; onReset: () => void; categoriaId: number; }) => {
|
||||
const { data, isLoading, error } = useQuery<ResultadoDetalladoMunicipio>({
|
||||
queryKey: ['municipioDetalle', ambitoId, categoriaId],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}?categoriaId=${categoriaId}`)).data,
|
||||
enabled: !!ambitoId,
|
||||
});
|
||||
|
||||
if (!ambitoId) return (<div className="detalle-placeholder"><h3>Provincia de Buenos Aires</h3><p>Seleccione un municipio en el mapa para ver los resultados detallados.</p></div>);
|
||||
if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados...</p></div>);
|
||||
if (error) return <div className="detalle-error">Error al cargar los datos del municipio.</div>;
|
||||
|
||||
return (
|
||||
<div className="detalle-content">
|
||||
<button className="reset-button-panel" onClick={onReset}>← VOLVER</button>
|
||||
<h3>{data?.municipioNombre}</h3>
|
||||
<div className="detalle-metricas">
|
||||
<span><strong>Escrutado:</strong> {(data?.porcentajeEscrutado ?? 0).toFixed(2)}%</span>
|
||||
<span><strong>Participación:</strong> {(data?.porcentajeParticipacion ?? 0).toFixed(2)}%</span>
|
||||
</div>
|
||||
<ul className="resultados-lista">
|
||||
{(data?.resultados ?? []).map((r, index) => (
|
||||
<li key={`${r.nombre}-${index}`}>
|
||||
<div className="resultado-info">
|
||||
<span className="partido-nombre">{r.nombre}</span>
|
||||
<span className="partido-votos">{r.votos.toLocaleString('es-AR')} ({r.porcentaje.toFixed(2)}%)</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${r.porcentaje}%`,
|
||||
backgroundColor: r.color || DEFAULT_MAP_COLOR
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</li >
|
||||
))}
|
||||
</ul >
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
const Legend = ({ resultados, nombresAgrupaciones }: { resultados: Map<string, ResultadoMapa>, nombresAgrupaciones: Map<string, string> }) => {
|
||||
|
||||
const legendItems = useMemo(() => {
|
||||
const ganadoresUnicos = new Map<string, { nombre: string; color: string }>();
|
||||
|
||||
resultados.forEach(resultado => {
|
||||
if (resultado.colorGanador && !ganadoresUnicos.has(resultado.agrupacionGanadoraId)) {
|
||||
ganadoresUnicos.set(resultado.agrupacionGanadoraId, {
|
||||
nombre: nombresAgrupaciones.get(resultado.agrupacionGanadoraId) || 'Desconocido',
|
||||
color: resultado.colorGanador
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(ganadoresUnicos.values());
|
||||
|
||||
}, [resultados, nombresAgrupaciones]);
|
||||
|
||||
return (
|
||||
<div className="legend">
|
||||
<h4>Leyenda de Ganadores</h4>
|
||||
{legendItems.map(item => (
|
||||
<div key={item.nombre} className="legend-item">
|
||||
<div className="legend-color-box" style={{ backgroundColor: item.color }} />
|
||||
<span>{item.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapaBsAs;
|
||||
@@ -1,306 +0,0 @@
|
||||
// src/components/MapaBsAsSecciones.tsx
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { geoCentroid } from 'd3-geo';
|
||||
import { getDetalleSeccion, API_BASE_URL, assetBaseUrl } from '../apiService';
|
||||
import { type ResultadoDetalleSeccion } from '../apiService';
|
||||
import './MapaBsAs.css';
|
||||
|
||||
// --- Interfaces y Tipos ---
|
||||
type PointTuple = [number, number];
|
||||
interface ResultadoMapaSeccion {
|
||||
seccionId: string;
|
||||
seccionNombre: string;
|
||||
agrupacionGanadoraId: string | null;
|
||||
colorGanador: string | null;
|
||||
}
|
||||
interface Agrupacion { id: string; nombre: string; }
|
||||
interface Categoria { id: number; nombre: string; }
|
||||
type SeccionGeography = {
|
||||
rsmKey: string;
|
||||
properties: { seccion: string; fna: string; };
|
||||
};
|
||||
|
||||
// --- Constantes ---
|
||||
const DEFAULT_MAP_COLOR = '#E0E0E0';
|
||||
const CATEGORIAS: Categoria[] = [{ id: 5, nombre: 'Senadores' }, { id: 6, nombre: 'Diputados' }];
|
||||
const SECCION_ID_TO_ROMAN: Record<string, string> = { '1': 'I', '2': 'II', '3': 'III', '4': 'IV', '5': 'V', '6': 'VI', '7': 'VII', '8': 'VIII' };
|
||||
const ROMAN_TO_SECCION_ID: Record<string, string> = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5', 'VI': '6', 'VII': '7', 'VIII': '8' };
|
||||
const NOMBRES_SECCIONES: Record<string, string> = {
|
||||
'I': 'Sección Primera', 'II': 'Sección Segunda', 'III': 'Sección Tercera', 'IV': 'Sección Cuarta',
|
||||
'V': 'Sección Quinta', 'VI': 'Sección Sexta', 'VII': 'Sección Séptima', 'VIII': 'Sección Capital'
|
||||
};
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 5;
|
||||
const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -1000], [1100, 800]];
|
||||
const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM };
|
||||
|
||||
|
||||
// --- Componente de Detalle ---
|
||||
const DetalleSeccion = ({ seccion, categoriaId, onReset }: { seccion: SeccionGeography | null, categoriaId: number, onReset: () => void }) => {
|
||||
const seccionId = seccion ? ROMAN_TO_SECCION_ID[seccion.properties.seccion] : null;
|
||||
|
||||
const { data: resultadosDetalle, isLoading, error } = useQuery<ResultadoDetalleSeccion[]>({
|
||||
queryKey: ['detalleSeccion', seccionId, categoriaId],
|
||||
queryFn: () => getDetalleSeccion(seccionId!, categoriaId),
|
||||
enabled: !!seccionId,
|
||||
});
|
||||
|
||||
if (!seccion) {
|
||||
return (
|
||||
<div className="detalle-placeholder">
|
||||
<h3>Resultados por Sección</h3>
|
||||
<p>Haga clic en una sección del mapa para ver los resultados detallados.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados de la sección...</p></div>);
|
||||
if (error) return <div className="detalle-error">Error al cargar los datos de la sección.</div>;
|
||||
|
||||
const nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida";
|
||||
|
||||
return (
|
||||
<div className="detalle-content">
|
||||
<button className="reset-button-panel" onClick={onReset}>← VOLVER</button>
|
||||
<h3>{nombreSeccionLegible}</h3>
|
||||
<ul className="resultados-lista">
|
||||
{resultadosDetalle?.map((r) => (
|
||||
<li key={r.id}>
|
||||
<div className="resultado-info">
|
||||
<span className="partido-nombre">{r.nombre}</span>
|
||||
<span className="partido-votos">{r.votos.toLocaleString('es-AR')} ({r.porcentaje.toFixed(2)}%)</span>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${r.porcentaje}%`, backgroundColor: r.color || DEFAULT_MAP_COLOR }}></div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Componente de Controles del Mapa ---
|
||||
const ControlesMapa = ({ onReset }: { onReset: () => void }) => (
|
||||
<div className="map-controls">
|
||||
<button onClick={onReset}>← VOLVER</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- Componente Principal ---
|
||||
const MapaBsAsSecciones = () => {
|
||||
const [position, setPosition] = useState(INITIAL_POSITION);
|
||||
const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6);
|
||||
const [clickedSeccion, setClickedSeccion] = useState<SeccionGeography | null>(null);
|
||||
const [tooltipContent, setTooltipContent] = useState('');
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({
|
||||
queryKey: ['mapaGeoDataSecciones'],
|
||||
queryFn: async () => (await axios.get(`${assetBaseUrl}/secciones-electorales-pba.topojson`)).data,
|
||||
});
|
||||
|
||||
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapaSeccion[]>({
|
||||
queryKey: ['mapaResultadosPorSeccion', selectedCategoriaId],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-seccion?categoriaId=${selectedCategoriaId}`)).data,
|
||||
});
|
||||
|
||||
const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
|
||||
queryKey: ['catalogoAgrupaciones'],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data,
|
||||
});
|
||||
|
||||
const { nombresAgrupaciones, resultadosPorSeccionRomana } = useMemo<{
|
||||
nombresAgrupaciones: Map<string, string>;
|
||||
resultadosPorSeccionRomana: Map<string, ResultadoMapaSeccion>;
|
||||
}>((
|
||||
) => {
|
||||
const nombresMap = new Map<string, string>();
|
||||
const resultadosMap = new Map<string, ResultadoMapaSeccion>();
|
||||
|
||||
if (agrupacionesData) {
|
||||
agrupacionesData.forEach(a => nombresMap.set(a.id, a.nombre));
|
||||
}
|
||||
if (resultadosData) {
|
||||
resultadosData.forEach(r => {
|
||||
const roman = SECCION_ID_TO_ROMAN[r.seccionId];
|
||||
if (roman) resultadosMap.set(roman, r);
|
||||
});
|
||||
}
|
||||
return { nombresAgrupaciones: nombresMap, resultadosPorSeccionRomana: resultadosMap };
|
||||
}, [agrupacionesData, resultadosData]);
|
||||
|
||||
const isLoading = isLoadingGeo || isLoadingResultados || isLoadingAgrupaciones;
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setClickedSeccion(null);
|
||||
setPosition(INITIAL_POSITION);
|
||||
}, []);
|
||||
|
||||
const handleGeographyClick = useCallback((geo: SeccionGeography) => {
|
||||
if (clickedSeccion?.rsmKey === geo.rsmKey) {
|
||||
handleReset();
|
||||
} else {
|
||||
const centroid = geoCentroid(geo as any) as PointTuple;
|
||||
setPosition({ center: centroid, zoom: 2 });
|
||||
setClickedSeccion(geo);
|
||||
}
|
||||
}, [clickedSeccion, handleReset]);
|
||||
|
||||
const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => {
|
||||
if (newPosition.zoom <= MIN_ZOOM) {
|
||||
if (position.zoom > MIN_ZOOM || clickedSeccion !== null) {
|
||||
handleReset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (newPosition.zoom < position.zoom && clickedSeccion !== null) {
|
||||
setClickedSeccion(null);
|
||||
}
|
||||
setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleReset]);
|
||||
|
||||
const getSectionFillColor = (seccionRomana: string) => {
|
||||
return resultadosPorSeccionRomana.get(seccionRomana)?.colorGanador || DEFAULT_MAP_COLOR;
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (position.zoom < MAX_ZOOM) {
|
||||
setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="mapa-wrapper">
|
||||
<div className="mapa-container">
|
||||
{isLoading ? <div className="spinner"></div> : (
|
||||
<ComposableMap
|
||||
key={selectedCategoriaId}
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }}
|
||||
className="rsm-svg"
|
||||
data-tooltip-id="seccion-tooltip"
|
||||
>
|
||||
<ZoomableGroup
|
||||
center={position.center}
|
||||
zoom={position.zoom}
|
||||
onMoveEnd={(newPosition: { coordinates: PointTuple; zoom: number }) => {
|
||||
setIsPanning(false);
|
||||
handleMoveEnd(newPosition);
|
||||
}}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
translateExtent={TRANSLATE_EXTENT}
|
||||
className={isPanning ? 'panning' : ''}
|
||||
onMoveStart={() => setIsPanning(true)}
|
||||
filterZoomEvent={(e: WheelEvent) => {
|
||||
if (e.deltaY > 0) {
|
||||
handleReset();
|
||||
} else if (e.deltaY < 0) {
|
||||
handleZoomIn();
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
{geoData && (
|
||||
<Geographies geography={geoData}>
|
||||
{({ geographies }: { geographies: SeccionGeography[] }) =>
|
||||
geographies.map((geo) => {
|
||||
const seccionRomana = geo.properties.seccion;
|
||||
const resultado = resultadosPorSeccionRomana.get(seccionRomana);
|
||||
const nombreGanador = resultado?.agrupacionGanadoraId ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
|
||||
const isSelected = clickedSeccion?.rsmKey === geo.rsmKey;
|
||||
const isFaded = clickedSeccion && !isSelected;
|
||||
const isClickable = !!resultado;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey + (isSelected ? '-selected' : '')}
|
||||
geography={geo as any}
|
||||
data-tooltip-id="seccion-tooltip"
|
||||
onClick={isClickable ? () => handleGeographyClick(geo) : undefined}
|
||||
onMouseEnter={() => {
|
||||
if (isClickable) {
|
||||
const nombreSeccionLegible = NOMBRES_SECCIONES[geo.properties.seccion] || "Sección Desconocida";
|
||||
setTooltipContent(`${nombreSeccionLegible}: ${nombreGanador}`);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setTooltipContent("")}
|
||||
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`}
|
||||
fill={getSectionFillColor(seccionRomana)}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
)}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
)}
|
||||
{clickedSeccion && <ControlesMapa onReset={handleReset} />}
|
||||
<Tooltip id="seccion-tooltip" content={tooltipContent} />
|
||||
</div>
|
||||
<div className="info-panel">
|
||||
<div className="mapa-categoria-selector">
|
||||
<select
|
||||
className="mapa-categoria-combobox"
|
||||
value={selectedCategoriaId}
|
||||
onChange={(e) => {
|
||||
setSelectedCategoriaId(Number(e.target.value));
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
{CATEGORIAS.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.nombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<DetalleSeccion seccion={clickedSeccion} categoriaId={selectedCategoriaId} onReset={handleReset} />
|
||||
<LegendSecciones resultados={resultadosPorSeccionRomana} nombresAgrupaciones={nombresAgrupaciones} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-componente para la Leyenda (sin cambios) ---
|
||||
const LegendSecciones = ({ resultados, nombresAgrupaciones }: { resultados: Map<string, ResultadoMapaSeccion>, nombresAgrupaciones: Map<string, string> }) => {
|
||||
const legendItems = useMemo(() => {
|
||||
const ganadoresUnicos = new Map<string, { nombre: string; color: string }>();
|
||||
resultados.forEach(resultado => {
|
||||
if (resultado.agrupacionGanadoraId && resultado.colorGanador && !ganadoresUnicos.has(resultado.agrupacionGanadoraId)) {
|
||||
ganadoresUnicos.set(resultado.agrupacionGanadoraId, {
|
||||
nombre: nombresAgrupaciones.get(resultado.agrupacionGanadoraId) || 'Desconocido',
|
||||
color: resultado.colorGanador
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(ganadoresUnicos.values());
|
||||
}, [resultados, nombresAgrupaciones]);
|
||||
|
||||
return (
|
||||
<div className="legend">
|
||||
<h4>Leyenda de Ganadores</h4>
|
||||
{legendItems.map(item => (
|
||||
<div key={item.nombre} className="legend-item">
|
||||
<div className="legend-color-box" style={{ backgroundColor: item.color }} />
|
||||
<span>{item.nombre}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapaBsAsSecciones;
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getSeccionesElectorales, getRankingMunicipiosPorSeccion } from '../apiService';
|
||||
import type { MunicipioSimple, ApiResponseRankingMunicipio, RankingPartido } from '../types/types';
|
||||
import './ResultadosTablaSeccionWidget.css';
|
||||
|
||||
type DisplayMode = 'porcentaje' | 'votos' | 'ambos';
|
||||
type DisplayOption = {
|
||||
value: DisplayMode;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const displayModeOptions: readonly DisplayOption[] = [
|
||||
{ value: 'porcentaje', label: 'Ver Porcentajes' },
|
||||
{ value: 'votos', label: 'Ver Votos' },
|
||||
{ value: 'ambos', label: 'Ver Ambos' },
|
||||
];
|
||||
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 10 }),
|
||||
};
|
||||
|
||||
const formatPercent = (porcentaje: number) => `${porcentaje.toFixed(2).replace('.', ',')}%`;
|
||||
// Nueva función para formatear votos con separador de miles
|
||||
const formatVotos = (votos: number) => votos.toLocaleString('es-AR');
|
||||
|
||||
// --- NUEVO COMPONENTE HELPER PARA RENDERIZAR CELDAS ---
|
||||
const CellRenderer = ({ partido, mode }: { partido?: RankingPartido, mode: DisplayMode }) => {
|
||||
if (!partido) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'votos':
|
||||
return <span>{formatVotos(partido.votos)}</span>;
|
||||
case 'ambos':
|
||||
return (
|
||||
<div className="cell-ambos">
|
||||
<span>{formatVotos(partido.votos)}</span>
|
||||
<small>{formatPercent(partido.porcentaje)}</small>
|
||||
</div>
|
||||
);
|
||||
case 'porcentaje':
|
||||
default:
|
||||
return <span>{formatPercent(partido.porcentaje)}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const ResultadosRankingMunicipioWidget = () => {
|
||||
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
const [displayMode, setDisplayMode] = useState<DisplayOption>(displayModeOptions[0]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSecciones = async () => {
|
||||
const seccionesData = await getSeccionesElectorales();
|
||||
if (seccionesData && seccionesData.length > 0) {
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
setSecciones(seccionesData);
|
||||
if (!selectedSeccion) {
|
||||
setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre });
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSecciones();
|
||||
}, [selectedSeccion]);
|
||||
|
||||
const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]);
|
||||
|
||||
const { data: rankingData, isLoading } = useQuery<ApiResponseRankingMunicipio>({
|
||||
queryKey: ['rankingMunicipiosPorSeccion', selectedSeccion?.value],
|
||||
queryFn: () => getRankingMunicipiosPorSeccion(selectedSeccion!.value),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tabla-resultados-widget">
|
||||
<div className="tabla-header">
|
||||
<h3>Resultados por Municipio</h3>
|
||||
<div className="header-filters">
|
||||
<Select
|
||||
options={displayModeOptions}
|
||||
value={displayMode}
|
||||
onChange={(option) => setDisplayMode(option as DisplayOption)}
|
||||
styles={customSelectStyles}
|
||||
isSearchable={false}
|
||||
/>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={secciones.length === 0}
|
||||
styles={customSelectStyles}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabla-container">
|
||||
{isLoading ? <p>Cargando...</p> : !rankingData || rankingData.categorias.length === 0 ? <p>No hay datos.</p> : (
|
||||
<table>
|
||||
<thead>
|
||||
{/* --- Fila 1: Nombres de Categorías --- */}
|
||||
<tr>
|
||||
<th rowSpan={3} className="sticky-col municipio-header">Municipio</th>
|
||||
{rankingData.categorias.map(cat => (
|
||||
<th key={cat.id} colSpan={4} className="categoria-header">
|
||||
{cat.nombre}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* --- Fila 2: Puestos --- */}
|
||||
<tr>
|
||||
{rankingData.categorias.flatMap(cat => [
|
||||
<th key={`${cat.id}-p1`} colSpan={2} className="puesto-header">1° Puesto</th>,
|
||||
<th key={`${cat.id}-p2`} colSpan={2} className="puesto-header category-divider-header">2° Puesto</th>
|
||||
])}
|
||||
</tr>
|
||||
{/* --- Fila 3: Sub-cabeceras (Partido y %) --- */}
|
||||
<tr>
|
||||
{rankingData.categorias.flatMap(cat => [
|
||||
<th key={`${cat.id}-p1-partido`} className="sub-header">Partido</th>,
|
||||
<th key={`${cat.id}-p1-porc`} className="sub-header">%</th>,
|
||||
<th key={`${cat.id}-p2-partido`} className="sub-header category-divider-header">Partido</th>,
|
||||
<th key={`${cat.id}-p2-porc`} className="sub-header">%</th>
|
||||
])}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankingData.resultados.map(municipio => (
|
||||
<tr key={municipio.municipioId}>
|
||||
<td className="sticky-col">{municipio.municipioNombre}</td>
|
||||
{rankingData.categorias.flatMap(cat => {
|
||||
const resCategoria = municipio.resultadosPorCategoria[cat.id];
|
||||
const primerPuesto = resCategoria?.ranking[0];
|
||||
const segundoPuesto = resCategoria?.ranking[1];
|
||||
|
||||
return [
|
||||
// --- Celdas para el 1° Puesto ---
|
||||
<td key={`${municipio.municipioId}-${cat.id}-p1-partido`} className="cell-partido">
|
||||
{primerPuesto?.nombreCorto || '-'}
|
||||
</td>,
|
||||
<td key={`${municipio.municipioId}-${cat.id}-p1-porc`} className="cell-porcentaje">
|
||||
{primerPuesto ? <CellRenderer partido={primerPuesto} mode={displayMode.value} /> : '-'}
|
||||
</td>,
|
||||
|
||||
// --- Celdas para el 2° Puesto ---
|
||||
<td key={`${municipio.municipioId}-${cat.id}-p2-partido`} className="cell-partido category-divider">
|
||||
{segundoPuesto?.nombreCorto || '-'}
|
||||
</td>,
|
||||
<td key={`${municipio.municipioId}-${cat.id}-p2-porc`} className="cell-porcentaje">
|
||||
{segundoPuesto ? <CellRenderer partido={segundoPuesto} mode={displayMode.value} /> : '-'}
|
||||
</td>
|
||||
];
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getSeccionesElectorales, getResultadosTablaDetallada } from '../apiService';
|
||||
import type { MunicipioSimple, ApiResponseTablaDetallada } from '../types/types';
|
||||
import './ResultadosTablaSeccionWidget.css';
|
||||
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 10 }),
|
||||
};
|
||||
|
||||
const formatPercent = (porcentaje: number) => `${porcentaje.toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
export const ResultadosTablaDetalladaWidget = () => {
|
||||
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSecciones = async () => {
|
||||
const seccionesData = await getSeccionesElectorales();
|
||||
if (seccionesData && seccionesData.length > 0) {
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
setSecciones(seccionesData);
|
||||
if (!selectedSeccion) {
|
||||
setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre });
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchSecciones();
|
||||
}, [selectedSeccion]);
|
||||
|
||||
const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]);
|
||||
|
||||
const { data: tablaData, isLoading } = useQuery<ApiResponseTablaDetallada>({
|
||||
queryKey: ['resultadosTablaDetallada', selectedSeccion?.value],
|
||||
queryFn: () => getResultadosTablaDetallada(selectedSeccion!.value),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="tabla-resultados-widget">
|
||||
<div className="tabla-header">
|
||||
<h3>Resultados por Sección Electoral</h3>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={secciones.length === 0}
|
||||
styles={customSelectStyles}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tabla-container">
|
||||
{isLoading ? <p>Cargando...</p> : !tablaData || tablaData.categorias.length === 0 ? <p>No hay datos disponibles.</p> : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2} className="sticky-col municipio-header">Municipio</th>
|
||||
{tablaData.categorias.map(cat => (
|
||||
<th key={cat.id} colSpan={tablaData.partidosPorCategoria[cat.id]?.length || 1} className="categoria-header">
|
||||
{cat.nombre}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
{tablaData.categorias.flatMap(cat =>
|
||||
(tablaData.partidosPorCategoria[cat.id] || []).map(partido => (
|
||||
<th key={`header-${cat.id}-${partido.id}`} className="partido-header">
|
||||
<span>{partido.puesto}° {partido.nombre} - </span>
|
||||
<span className="porcentaje-total">{formatPercent(partido.porcentajeTotalSeccion)}</span>
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tablaData.resultadosPorMunicipio.map(municipio => (
|
||||
<tr key={municipio.municipioId}>
|
||||
<td className="sticky-col">{municipio.municipioNombre}</td>
|
||||
{tablaData.categorias.flatMap(cat =>
|
||||
(tablaData.partidosPorCategoria[cat.id] || []).map(partido => {
|
||||
const porcentaje = municipio.celdas[cat.id]?.[partido.id];
|
||||
return (
|
||||
<td key={`${municipio.municipioId}-${cat.id}-${partido.id}`}>
|
||||
{typeof porcentaje === 'number' ? formatPercent(porcentaje) : '-'}
|
||||
</td>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,215 +0,0 @@
|
||||
/* ==========================================================================
|
||||
ResultadosTablaSeccionWidget.css
|
||||
========================================================================== */
|
||||
|
||||
.tabla-resultados-widget {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid #e9ecef;
|
||||
color: #333;
|
||||
max-width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.tabla-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.header-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tabla-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.tabla-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tabla-container table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- CABECERAS (THEAD) --- */
|
||||
.tabla-container thead th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
.tabla-container thead th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* Fila 1: Categorías (SENADORES, CONCEJALES) */
|
||||
.tabla-container th.categoria-header {
|
||||
border-bottom: 1px solid #adb5bd;
|
||||
border-right: 2px solid #adb5bd;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Fila 2: Puestos (1° Puesto, 2° Puesto) */
|
||||
.tabla-container th.puesto-header {
|
||||
border-bottom: 1px solid #adb5bd;
|
||||
border-right: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
/* Fila 3: Sub-cabeceras (Partido, %) */
|
||||
.tabla-container th.sub-header {
|
||||
font-weight: 500;
|
||||
font-size: 0.7rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* --- CUERPO DE LA TABLA (TBODY) --- */
|
||||
.tabla-container tbody td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tabla-container tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.tabla-container tbody tr:nth-of-type(even) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.tabla-container tbody tr:hover {
|
||||
background-color: #e2e6ea;
|
||||
}
|
||||
|
||||
/* Celdas específicas */
|
||||
.tabla-container .cell-partido {
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
padding-left: 15px; /* Añade un poco de espacio a la izquierda */
|
||||
border-right: 1px solid #e9ecef; /* Línea fina entre partido y % */
|
||||
}
|
||||
.tabla-container .cell-porcentaje {
|
||||
text-align: right;
|
||||
font-family: 'Roboto Mono', 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
padding-right: 15px; /* Añade un poco de espacio a la derecha */
|
||||
}
|
||||
|
||||
.tabla-container th.sub-header-init {
|
||||
border-right: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
/* Líneas divisorias entre categorías */
|
||||
.tabla-container .category-divider-header {
|
||||
border-left: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
.tabla-container .category-divider {
|
||||
border-left: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
/* Columna fija de Municipio */
|
||||
.tabla-container th.sticky-col,
|
||||
.tabla-container td.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: #ffffff;
|
||||
z-index: 1;
|
||||
border-right: 2px solid #adb5bd; /* Línea divisoria gruesa para la columna fija */
|
||||
}
|
||||
.tabla-container thead th.sticky-col {
|
||||
background-color: #f8f9fa;
|
||||
z-index: 2;
|
||||
}
|
||||
.tabla-container tbody tr:nth-of-type(even) td.sticky-col {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.tabla-container tbody tr:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
/* Primera columna (Municipios) */
|
||||
.tabla-container td:first-child {
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
text-align: left;
|
||||
border-left: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
/* Columnas de porcentajes */
|
||||
.tabla-container td:not(:first-child) {
|
||||
text-align: left; /* Opcional: puedes poner 'center' si prefieres */
|
||||
font-size: 0.85rem; /* Un poco más pequeño para que entre bien */
|
||||
}
|
||||
|
||||
/* Línea divisoria vertical para las celdas de datos */
|
||||
.tabla-container tbody td.category-divider {
|
||||
border-right: 1px solid #ced4da;
|
||||
border-left: 2px solid #adb5bd;
|
||||
}
|
||||
|
||||
/* Estilos para la columna fija (Sticky) */
|
||||
.tabla-container th.sticky-col,
|
||||
.tabla-container td.sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
background-color: #ffffff; /* Fondo blanco para que tape el contenido */
|
||||
z-index: 1;
|
||||
}
|
||||
.tabla-container thead th.sticky-col {
|
||||
background-color: #f8f9fa; /* Mismo color que el resto de la cabecera */
|
||||
z-index: 2;
|
||||
}
|
||||
.tabla-container tbody tr:nth-of-type(even) td.sticky-col {
|
||||
background-color: #f8f9fa; /* Para que coincida con el fondo de la fila */
|
||||
}
|
||||
|
||||
/* Contenedor principal de la celda para alinear contenido */
|
||||
.cell-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* Nombre del partido dentro de la celda */
|
||||
.cell-partido-nombre {
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
/* Contenedor para la vista "ambos" (votos y porcentaje) */
|
||||
.cell-ambos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cell-ambos small {
|
||||
font-size: 0.8em;
|
||||
color: #6c757d;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// src/components/ResumenGeneralWidget.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
export const ResumenGeneralWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
const aggregatedData = useMemo(() => {
|
||||
if (!categorias) return null;
|
||||
|
||||
const legislativeCategories = categorias.filter(c => c.categoriaId === 5 || c.categoriaId === 6);
|
||||
if (legislativeCategories.length === 0) return null;
|
||||
|
||||
const partyMap = new Map<string, Omit<ResultadoTicker, 'porcentaje'>>();
|
||||
|
||||
legislativeCategories.forEach(category => {
|
||||
category.resultados.forEach(party => {
|
||||
const existing = partyMap.get(party.id);
|
||||
if (existing) {
|
||||
existing.votos += party.votos;
|
||||
} else {
|
||||
// Clonamos el objeto para no modificar el original
|
||||
partyMap.set(party.id, { ...party });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const resultsArray = Array.from(partyMap.values());
|
||||
const grandTotalVotes = resultsArray.reduce((sum, party) => sum + party.votos, 0);
|
||||
|
||||
const finalResults: ResultadoTicker[] = resultsArray
|
||||
.map(party => ({
|
||||
...party,
|
||||
porcentaje: grandTotalVotes > 0 ? (party.votos * 100 / grandTotalVotes) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.votos - a.votos);
|
||||
|
||||
const avgMesas = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0), 0) / legislativeCategories.length;
|
||||
const avgParticipacion = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.participacionPorcentaje ?? 0), 0) / legislativeCategories.length;
|
||||
|
||||
return {
|
||||
resultados: finalResults,
|
||||
estadoRecuento: { mesasTotalizadasPorcentaje: avgMesas, participacionPorcentaje: avgParticipacion }
|
||||
};
|
||||
}, [categorias]);
|
||||
|
||||
if (isLoading) return <div className="ticker-card loading" style={{ gridColumn: '1 / -1' }}>Cargando resumen general...</div>;
|
||||
if (error || !aggregatedData) return <div className="ticker-card error" style={{ gridColumn: '1 / -1' }}>No hay datos para el resumen general.</div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = aggregatedData.resultados;
|
||||
if (aggregatedData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = aggregatedData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = aggregatedData.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-general`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = aggregatedData.resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card" style={{ gridColumn: '1 / -1' }}>
|
||||
<div className="ticker-header">
|
||||
<h3>RESUMEN LEGISLATIVO PROVINCIAL</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.mesasTotalizadasPorcentaje)}</strong></span>
|
||||
<span>Part (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.participacionPorcentaje)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
// src/components/SenadoresPorSeccionWidget.tsx
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }),
|
||||
};
|
||||
|
||||
const CATEGORIA_ID = 5; // ID para Senadores
|
||||
|
||||
export const SenadoresPorSeccionWidget = () => {
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10);
|
||||
|
||||
// Ahora usamos useQuery para obtener las secciones filtradas
|
||||
const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({
|
||||
queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché
|
||||
queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría
|
||||
});
|
||||
|
||||
// useEffect para establecer la primera sección por defecto
|
||||
useEffect(() => {
|
||||
if (secciones.length > 0 && !selectedSeccion) {
|
||||
// Ordenamos aquí solo para la selección inicial
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
|
||||
setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre });
|
||||
}
|
||||
}, [secciones, selectedSeccion]);
|
||||
|
||||
const seccionOptions = useMemo(() =>
|
||||
secciones
|
||||
.map(s => ({ value: s.id, label: s.nombre }))
|
||||
.sort((a, b) => { // Mantenemos el orden en el dropdown
|
||||
const orden = new Map([
|
||||
['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3],
|
||||
['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7]
|
||||
]);
|
||||
return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99);
|
||||
}),
|
||||
[secciones]);
|
||||
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
const resultados = data?.resultados || [];
|
||||
|
||||
let displayResults: ResultadoTicker[] = resultados;
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-senadores-${selectedSeccion?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>SENADORES POR SECCIÓN</h3>
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={(option) => setSelectedSeccion(option)}
|
||||
isLoading={isLoadingSecciones}
|
||||
placeholder="Seleccionar sección..."
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
|
||||
{!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
// src/components/SenadoresTickerWidget.tsx
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../apiService';
|
||||
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css'; // Reutilizamos los mismos estilos
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const CATEGORIA_ID = 5; // ID para Senadores
|
||||
|
||||
export const SenadoresTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
// Usamos useMemo para encontrar los datos específicos de Senadores
|
||||
const senadoresData = useMemo(() => {
|
||||
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
|
||||
}, [categorias]);
|
||||
|
||||
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
|
||||
if (error || !senadoresData) return <div className="ticker-card error"><p>Datos de Senadores no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = senadoresData.resultados;
|
||||
if (senadoresData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = senadoresData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = senadoresData.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-senadores`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = senadoresData.resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>RESUMEN DE {senadoresData.categoriaNombre}</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas: <strong>{formatPercent(senadoresData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
|
||||
<span>Part: <strong>{formatPercent(senadoresData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
// src/components/SenadoresWidget.tsx
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select'; // Importamos react-select
|
||||
import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../apiService'; // Usamos las funciones genéricas
|
||||
import type { MunicipioSimple, ResultadoTicker } from '../types/types';
|
||||
import { ImageWithFallback } from './ImageWithFallback';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
// Estilos para el selector, podemos moverlos a un archivo común más adelante
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 100 }),
|
||||
};
|
||||
|
||||
// Constante para la categoría de este widget
|
||||
const CATEGORIA_ID = 5; // Senadores
|
||||
|
||||
export const SenadoresWidget = () => {
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
const { data: configData } = useQuery({
|
||||
queryKey: ['configuracionPublica'],
|
||||
queryFn: getConfiguracionPublica,
|
||||
staleTime: 0,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
// Usamos la clave de configuración del Ticker, ya que es para Senadores/Diputados
|
||||
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
|
||||
|
||||
const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({
|
||||
queryKey: ['municipios', CATEGORIA_ID], // Key única para la caché
|
||||
queryFn: () => getMunicipios(), // Pasamos el ID de la categoría
|
||||
});
|
||||
|
||||
// useEffect para establecer "ALBERTI" por defecto
|
||||
useEffect(() => {
|
||||
if (municipios.length > 0 && !selectedMunicipio) {
|
||||
const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'ALBERTI');
|
||||
if (laPlata) {
|
||||
setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre });
|
||||
}
|
||||
}
|
||||
}, [municipios, selectedMunicipio]);
|
||||
|
||||
const municipioOptions = useMemo(() =>
|
||||
municipios
|
||||
.map(m => ({ value: m.id, label: m.nombre }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[municipios]);
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = resultados || [];
|
||||
if (resultados && resultados.length > cantidadAMostrar) {
|
||||
const topParties = resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
id: `otros-senadores-${selectedMunicipio?.value}`,
|
||||
nombre: 'Otros',
|
||||
nombreCorto: 'Otros',
|
||||
color: '#888888',
|
||||
logoUrl: null,
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else if (resultados) {
|
||||
displayResults = resultados.slice(0, cantidadAMostrar);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-card">
|
||||
<div className="ticker-header">
|
||||
<h3>SENADORES POR MUNICIPIO</h3>
|
||||
<Select
|
||||
options={municipioOptions}
|
||||
value={selectedMunicipio}
|
||||
onChange={(option) => setSelectedMunicipio(option)}
|
||||
isLoading={isLoadingMunicipios}
|
||||
placeholder="Buscar municipio..."
|
||||
isClearable
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>}
|
||||
{!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>}
|
||||
{displayResults.map(partido => (
|
||||
<div key={partido.id} className="ticker-party">
|
||||
<div className="party-logo">
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} />
|
||||
</div>
|
||||
<div className="party-details">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
|
||||
</div>
|
||||
<div className="party-bar-background">
|
||||
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
|
||||
</div>
|
||||
<div className="party-candidate-name">
|
||||
{partido.nombreCandidato}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,172 +0,0 @@
|
||||
/* src/components/TelegramaWidget.css */
|
||||
.telegrama-container {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.telegrama-container h4 {
|
||||
margin-top: 0;
|
||||
color: #212529;
|
||||
font-size: 1.2em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filters-grid select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.filters-grid select:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex-grow: 1;
|
||||
padding: 0.75rem;
|
||||
font-size: 1em;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--primary-accent-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.search-button:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.telegrama-viewer {
|
||||
/* Se mantiene el min-height aquí para el estado inicial (antes de cargar el PDF) */
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: flex-start; /* Se cambia a flex-start para alinear el contenido arriba */
|
||||
justify-content: center;
|
||||
border-top: 1px solid #e9ecef;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
|
||||
.telegrama-viewer .message {
|
||||
color: #6c757d;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.telegrama-viewer .message.error {
|
||||
color: #d62728;
|
||||
}
|
||||
|
||||
.telegrama-content {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.telegrama-pdf-viewer {
|
||||
flex: 1 1 100%;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background-color: #f8f9fa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
/* --- INICIO DE LA CORRECCIÓN CLAVE --- */
|
||||
/* Se elimina min-height para que el contenedor se ajuste a la altura del PDF */
|
||||
/* Se añade max-height para controlar PDFs muy largos y activar el scroll */
|
||||
max-height: 80vh;
|
||||
/* --- FIN DE LA CORRECCIÓN CLAVE --- */
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.telegrama-pdf-viewer .react-pdf__Page {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.telegrama-pdf-viewer .react-pdf__Page__canvas {
|
||||
max-width: 100%;
|
||||
/*
|
||||
Se elimina max-height: 100% y se vuelve a height: auto !important
|
||||
para asegurar que la proporción se base únicamente en el ancho.
|
||||
*/
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.telegrama-metadata {
|
||||
flex: 1 1 300px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.telegrama-metadata h5 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1em;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.meta-item span {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.meta-item strong {
|
||||
color: #212529;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.telegrama-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// src/components/TelegramaWidget.tsx
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Select, { type FilterOptionOption } from 'react-select'; // <-- Importar react-select
|
||||
import {
|
||||
getSecciones,
|
||||
getMunicipiosPorSeccion,
|
||||
getEstablecimientosPorMunicipio, // <-- Nueva función
|
||||
getMesasPorEstablecimiento,
|
||||
getTelegramaPorId,
|
||||
assetBaseUrl
|
||||
} from '../apiService';
|
||||
import type { TelegramaData, CatalogoItem } from '../types/types';
|
||||
import './TelegramaWidget.css';
|
||||
|
||||
import { pdfjs, Document, Page } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `${assetBaseUrl}/pdf.worker.min.mjs`;
|
||||
|
||||
// Estilos para los selectores
|
||||
const customSelectStyles = {
|
||||
control: (base: any) => ({ ...base, marginBottom: '1rem' }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 10 }),
|
||||
};
|
||||
|
||||
// --- FUNCIÓN DE FILTRO "SMART SEARCH" ---
|
||||
const smartSearchFilter = (
|
||||
option: FilterOptionOption<{ label: string; value: string }>,
|
||||
inputValue: string
|
||||
) => {
|
||||
// 1. Si no hay entrada de búsqueda, muestra todas las opciones.
|
||||
if (!inputValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Normalizamos tanto la etiqueta de la opción como la entrada del usuario:
|
||||
// - a minúsculas
|
||||
// - quitamos los acentos (si fuera necesario, aunque aquí no tanto)
|
||||
const normalizedLabel = option.label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
const normalizedInput = inputValue.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
||||
|
||||
// 3. Dividimos la entrada del usuario en palabras individuales.
|
||||
const searchTerms = normalizedInput.split(' ').filter(term => term.length > 0);
|
||||
|
||||
// 4. La opción es válida si CADA TÉRMINO de búsqueda está incluido en la etiqueta.
|
||||
return searchTerms.every(term => normalizedLabel.includes(term));
|
||||
};
|
||||
|
||||
export const TelegramaWidget = () => {
|
||||
// Estados para los datos de los dropdowns
|
||||
const [secciones, setSecciones] = useState<CatalogoItem[]>([]);
|
||||
const [municipios, setMunicipios] = useState<CatalogoItem[]>([]);
|
||||
const [establecimientos, setEstablecimientos] = useState<CatalogoItem[]>([]);
|
||||
const [mesas, setMesas] = useState<CatalogoItem[]>([]);
|
||||
|
||||
// Estados para los valores seleccionados (adaptados para react-select)
|
||||
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedEstablecimiento, setSelectedEstablecimiento] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedMesa, setSelectedMesa] = useState<{ value: string; label: string } | null>(null);
|
||||
|
||||
// Estados para la visualización del telegrama (sin cambios)
|
||||
const [telegrama, setTelegrama] = useState<TelegramaData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cargar secciones iniciales y aplicar orden
|
||||
useEffect(() => {
|
||||
getSecciones().then(seccionesData => {
|
||||
const orden = new Map([
|
||||
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
|
||||
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
|
||||
]);
|
||||
const getOrden = (nombre: string) => {
|
||||
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
|
||||
return match ? orden.get(match[0]) ?? 99 : 99;
|
||||
};
|
||||
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
|
||||
setSecciones(seccionesData);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Cargar municipios cuando cambia la sección
|
||||
useEffect(() => {
|
||||
if (selectedSeccion) {
|
||||
setMunicipios([]); setEstablecimientos([]); setMesas([]);
|
||||
setSelectedMunicipio(null); setSelectedEstablecimiento(null); setSelectedMesa(null);
|
||||
getMunicipiosPorSeccion(selectedSeccion.value).then(setMunicipios);
|
||||
}
|
||||
}, [selectedSeccion]);
|
||||
|
||||
// Cargar establecimientos cuando cambia el municipio (SIN pasar por circuito)
|
||||
useEffect(() => {
|
||||
if (selectedMunicipio) {
|
||||
setEstablecimientos([]); setMesas([]);
|
||||
setSelectedEstablecimiento(null); setSelectedMesa(null);
|
||||
getEstablecimientosPorMunicipio(selectedMunicipio.value).then(setEstablecimientos);
|
||||
}
|
||||
}, [selectedMunicipio]);
|
||||
|
||||
// Cargar mesas cuando cambia el establecimiento
|
||||
useEffect(() => {
|
||||
if (selectedEstablecimiento) {
|
||||
setMesas([]);
|
||||
setSelectedMesa(null);
|
||||
getMesasPorEstablecimiento(selectedEstablecimiento.value).then(setMesas);
|
||||
}
|
||||
}, [selectedEstablecimiento]);
|
||||
|
||||
// Buscar el telegrama cuando se selecciona una mesa
|
||||
useEffect(() => {
|
||||
if (selectedMesa) {
|
||||
setLoading(true); setError(null); setTelegrama(null);
|
||||
getTelegramaPorId(selectedMesa.value)
|
||||
.then(setTelegrama)
|
||||
.catch(() => setError(`El telegrama para la mesa seleccionada, aún no se cargó en el sistema.`))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [selectedMesa]);
|
||||
|
||||
// Formateo de opciones para react-select
|
||||
const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]);
|
||||
const municipioOptions = useMemo(() => municipios.map(m => ({ value: m.id, label: m.nombre })), [municipios]);
|
||||
const establecimientoOptions = useMemo(() => establecimientos.map(e => ({ value: e.id, label: e.nombre })), [establecimientos]);
|
||||
const mesaOptions = useMemo(() => mesas.map(m => ({ value: m.id, label: m.nombre })), [mesas]);
|
||||
|
||||
return (
|
||||
<div className="telegrama-container">
|
||||
<h4>Consulta de Telegramas por Ubicación</h4>
|
||||
<div className="filters-grid">
|
||||
<Select
|
||||
options={seccionOptions}
|
||||
value={selectedSeccion}
|
||||
onChange={setSelectedSeccion}
|
||||
placeholder="1. Sección..."
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
<Select
|
||||
options={municipioOptions}
|
||||
value={selectedMunicipio}
|
||||
onChange={setSelectedMunicipio}
|
||||
placeholder="2. Municipio..."
|
||||
isDisabled={!selectedSeccion}
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
<Select
|
||||
options={establecimientoOptions}
|
||||
value={selectedEstablecimiento}
|
||||
onChange={setSelectedEstablecimiento}
|
||||
placeholder="3. Establecimiento..."
|
||||
isDisabled={!selectedMunicipio}
|
||||
styles={customSelectStyles}
|
||||
filterOption={smartSearchFilter}
|
||||
isSearchable // Habilitamos la búsqueda
|
||||
/>
|
||||
<Select
|
||||
options={mesaOptions}
|
||||
value={selectedMesa}
|
||||
onChange={setSelectedMesa}
|
||||
placeholder="4. Mesa..."
|
||||
isDisabled={!selectedEstablecimiento}
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="telegrama-viewer">
|
||||
{loading && <div className="spinner"></div>}
|
||||
{error && <p className="message error">{error}</p>}
|
||||
|
||||
{telegrama && (
|
||||
<div className="telegrama-content">
|
||||
<div className="telegrama-pdf-viewer">
|
||||
<Document
|
||||
file={`data:application/pdf;base64,${telegrama.contenidoBase64}`}
|
||||
onLoadError={(error) => setError(`Error al cargar el PDF: ${error.message}`)}
|
||||
loading={<div className="spinner"></div>}
|
||||
>
|
||||
<Page pageNumber={1} renderTextLayer={false} renderAnnotationLayer={false} />
|
||||
</Document>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !telegrama && !error && <p className="message">Seleccione una mesa para visualizar el telegrama.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
/* ==========================================================================
|
||||
TickerWidget.css (Versión Mejorada y Responsiva)
|
||||
========================================================================== */
|
||||
|
||||
/* --- Contenedor Principal del Widget --- */
|
||||
.ticker-card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem; /* Usamos rem para un padding relativo */
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* --- CAMBIO CLAVE: Establecemos un tamaño de fuente base --- */
|
||||
/* 1rem = al tamaño de fuente del contenedor padre. Si la página usa 16px,
|
||||
el widget usará 16px como base. Si usa 14px, se adaptará a 14px. */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* --- Cabecera del Ticker --- */
|
||||
.ticker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ticker-header h3 {
|
||||
margin: 0;
|
||||
color: #212529;
|
||||
/* El tamaño del título es 1.1 veces el tamaño base del widget */
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.ticker-stats {
|
||||
display: flex;
|
||||
gap: 1.25rem; /* 20px / 16px */
|
||||
font-size: 0.875rem; /* 14px / 16px */
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.ticker-stats strong {
|
||||
color: #0073e6;
|
||||
font-size: 1.1em; /* 1.1 veces el tamaño de su padre (0.875rem) */
|
||||
}
|
||||
|
||||
|
||||
/* --- Resultados (Grid de Partidos) --- */
|
||||
.ticker-results {
|
||||
display: grid;
|
||||
/* Mantenemos minmax para la responsividad de las columnas */
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.25rem; /* 20px */
|
||||
}
|
||||
|
||||
.ticker-party {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem; /* 10px */
|
||||
}
|
||||
|
||||
/* Logo del partido */
|
||||
.party-logo {
|
||||
flex-shrink: 0;
|
||||
width: 65px; /* Ligeramente más pequeño para no ser tan dominante */
|
||||
height: 65px;
|
||||
}
|
||||
.party-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain; /* Usar 'contain' es más seguro para logos */
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* Detalles (Nombre, Barra, Candidato) */
|
||||
.party-details {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticker-party .party-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline; /* Alinea la base del texto */
|
||||
margin-bottom: 0.3rem; /* 5px */
|
||||
}
|
||||
|
||||
.ticker-party .party-name {
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.ticker-party .party-percent {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem; /* Un poco más grande para destacar */
|
||||
flex-shrink: 0; /* Evita que el porcentaje se comprima */
|
||||
}
|
||||
|
||||
.party-bar-background {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
height: 8px; /* Un poco más delgada */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.party-bar-foreground {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.party-candidate-name {
|
||||
font-size: 0.8rem; /* 12.8px / 16px */
|
||||
color: #555;
|
||||
margin-top: 0.3rem; /* 4px */
|
||||
font-weight: 400; /* Ligeramente menos pesado */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
/* --- Media Query para Móviles --- */
|
||||
@media (max-width: 600px) {
|
||||
.ticker-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.ticker-header h3 {
|
||||
font-size: 1.1rem;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.ticker-results {
|
||||
/* En móvil, forzamos una sola columna */
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ticker-party .party-name,
|
||||
.party-candidate-name {
|
||||
white-space: normal; /* Permitimos que el texto se divida en varias líneas */
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
// src/components/DevApp.tsx
|
||||
import { BancasWidget } from './BancasWidget'
|
||||
import { CongresoWidget } from './CongresoWidget'
|
||||
import MapaBsAs from './MapaBsAs'
|
||||
import { DipSenTickerWidget } from './DipSenTickerWidget'
|
||||
import { TelegramaWidget } from './TelegramaWidget'
|
||||
import { ConcejalesWidget } from './ConcejalesWidget'
|
||||
import MapaBsAsSecciones from './MapaBsAsSecciones'
|
||||
import { SenadoresWidget } from './SenadoresWidget'
|
||||
import { DiputadosWidget } from './DiputadosWidget'
|
||||
import { ResumenGeneralWidget } from './ResumenGeneralWidget'
|
||||
import { SenadoresTickerWidget } from './SenadoresTickerWidget'
|
||||
import { DiputadosTickerWidget } from './DiputadosTickerWidget'
|
||||
import { ConcejalesTickerWidget } from './ConcejalesTickerWidget'
|
||||
import { DiputadosPorSeccionWidget } from './DiputadosPorSeccionWidget'
|
||||
import { SenadoresPorSeccionWidget } from './SenadoresPorSeccionWidget'
|
||||
import { ConcejalesPorSeccionWidget } from './ConcejalesPorSeccionWidget'
|
||||
import { ResultadosTablaDetalladaWidget } from './ResultadosTablaDetalladaWidget'
|
||||
import { ResultadosRankingMunicipioWidget } from './ResultadosRankingMunicipioWidget'
|
||||
import '../App.css';
|
||||
// src/components/common/DevApp.tsx
|
||||
import { BancasWidget } from '../../features/legislativas/provinciales/BancasWidget'
|
||||
import { CongresoWidget } from '../../features/legislativas/provinciales/CongresoWidget'
|
||||
import MapaBsAs from '../../features/legislativas/provinciales/MapaBsAs'
|
||||
import { DipSenTickerWidget } from '../../features/legislativas/provinciales/DipSenTickerWidget'
|
||||
import { TelegramaWidget } from '../../features/legislativas/provinciales/TelegramaWidget'
|
||||
import { ConcejalesWidget } from '../../features/legislativas/provinciales/ConcejalesWidget'
|
||||
import MapaBsAsSecciones from '../../features/legislativas/provinciales/MapaBsAsSecciones'
|
||||
import { SenadoresWidget } from '../../features/legislativas/provinciales/SenadoresWidget'
|
||||
import { DiputadosWidget } from '../../features/legislativas/provinciales/DiputadosWidget'
|
||||
import { ResumenGeneralWidget } from '../../features/legislativas/provinciales/ResumenGeneralWidget'
|
||||
import { SenadoresTickerWidget } from '../../features/legislativas/provinciales/SenadoresTickerWidget'
|
||||
import { DiputadosTickerWidget } from '../../features/legislativas/provinciales/DiputadosTickerWidget'
|
||||
import { ConcejalesTickerWidget } from '../../features/legislativas/provinciales/ConcejalesTickerWidget'
|
||||
import { DiputadosPorSeccionWidget } from '../../features/legislativas/provinciales/DiputadosPorSeccionWidget'
|
||||
import { SenadoresPorSeccionWidget } from '../../features/legislativas/provinciales/SenadoresPorSeccionWidget'
|
||||
import { ConcejalesPorSeccionWidget } from '../../features/legislativas/provinciales/ConcejalesPorSeccionWidget'
|
||||
import { ResultadosTablaDetalladaWidget } from '../../features/legislativas/provinciales/ResultadosTablaDetalladaWidget'
|
||||
import { ResultadosRankingMunicipioWidget } from '../../features/legislativas/provinciales/ResultadosRankingMunicipioWidget'
|
||||
import '../../App.css';
|
||||
|
||||
|
||||
export const DevApp = () => {
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/ImageWithFallback.tsx
|
||||
// src/components/common/ImageWithFallback.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/components/ParliamentLayout.tsx
|
||||
// src/components/common/ParliamentLayout.tsx
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { assetBaseUrl } from '../apiService';
|
||||
import { assetBaseUrl } from '../../apiService';
|
||||
import { handleImageFallback } from './imageFallback';
|
||||
|
||||
// Interfaces (no cambian)
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/SenateLayout.tsx
|
||||
// src/components/common/SenateLayout.tsx
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { handleImageFallback } from './imageFallback';
|
||||
import { assetBaseUrl } from '../apiService';
|
||||
import { assetBaseUrl } from '../../apiService';
|
||||
|
||||
// Interfaces
|
||||
interface SeatFillData {
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/imageFallback.ts
|
||||
// src/components/common/imageFallback.ts
|
||||
|
||||
export function handleImageFallback(selector: string, fallbackImageUrl: string) {
|
||||
// Le decimos a TypeScript que el resultado será una lista de elementos de imagen HTML
|
||||
Reference in New Issue
Block a user