Feat Front Widgets Refactizados y Ajustes Backend
This commit is contained in:
@@ -40,7 +40,3 @@
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -1,69 +1,20 @@
|
||||
// src/App.tsx
|
||||
import { useState } from 'react';
|
||||
import { MunicipioWidget } from './components/MunicipioWidget';
|
||||
import { MunicipioSelector } from './components/MunicipioSelector';
|
||||
import './App.css';
|
||||
import { ResumenProvincialWidget } from './components/ResumenProvincialWidget';
|
||||
import { BancasWidget } from './components/BancasWidget';
|
||||
import { TelegramasView } from './components/TelegramasView';
|
||||
import './App.css'
|
||||
//import { BancasWidget } from './components/BancasWidget'
|
||||
import MapaBsAs from './components/MapaBsAs'
|
||||
import { TickerWidget } from './components/TickerWidget'
|
||||
|
||||
function App() {
|
||||
const [selectedMunicipioId, setSelectedMunicipioId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Elecciones 2025 - Resultados en Vivo</h1>
|
||||
<section>
|
||||
<ResumenProvincialWidget distritoId="02" />
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
|
||||
<section>
|
||||
{/* Usamos el ID del distrito de Bs As ("02") */}
|
||||
<ResumenProvincialWidget distritoId="02" />
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section>
|
||||
<h2>Consulta por Municipio</h2>
|
||||
<MunicipioSelector onMunicipioChange={setSelectedMunicipioId} municipios={[]} />
|
||||
{selectedMunicipioId && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<MunicipioWidget municipioId={selectedMunicipioId} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Proyección de Bancas</h2>
|
||||
{/* Usamos el ID de la sección de La Plata ("0001") como ejemplo */}
|
||||
<BancasWidget seccionId="0001" />
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section>
|
||||
<h2>Consulta de Resultados por Municipio</h2>
|
||||
<MunicipioSelector onMunicipioChange={setSelectedMunicipioId} municipios={[]} />
|
||||
{selectedMunicipioId && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<MunicipioWidget municipioId={selectedMunicipioId} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section>
|
||||
<h2>Explorador de Telegramas</h2>
|
||||
<TelegramasView />
|
||||
</section>
|
||||
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
|
||||
<main>
|
||||
<TickerWidget />
|
||||
{/*<BancasWidget />*/}
|
||||
<MapaBsAs />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
56
Elecciones-Web/frontend/src/apiService.ts
Normal file
56
Elecciones-Web/frontend/src/apiService.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// src/apiService.ts
|
||||
import axios from 'axios';
|
||||
import type { MunicipioSimple, MunicipioDetalle, ResumenProvincial, ProyeccionBancas } from './types/types';
|
||||
|
||||
// La URL base de tu API. En un proyecto real, esto iría en un archivo .env
|
||||
const API_BASE_URL = 'http://localhost:5217/api'; // Ajusta el puerto si es necesario
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Obtiene los resultados para colorear el mapa inicial.
|
||||
*/
|
||||
export const getResultadosParaMapa = async (): Promise<any[]> => { // Usamos any[] temporalmente
|
||||
const response = await apiClient.get('/resultados/mapa');
|
||||
// Mapeamos la respuesta para que coincida con lo que el frontend espera
|
||||
return response.data.map((item: any) => ({
|
||||
municipioId: item.partidoId, // La propiedad en el frontend se llama municipioId
|
||||
agrupacionGanadoraId: item.agrupacionGanadoraId,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene la lista de todos los municipios con sus IDs y nombres.
|
||||
*/
|
||||
export const getMunicipios = async (): Promise<MunicipioSimple[]> => {
|
||||
const response = await apiClient.get('/catalogos/municipios');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el detalle completo de los resultados para un municipio específico.
|
||||
*/
|
||||
export const getDetallePorMunicipio = async (partidoId: string): Promise<MunicipioDetalle> => {
|
||||
const response = await apiClient.get(`/resultados/partido/${partidoId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Obtiene el resumen de resultados a nivel provincial.
|
||||
* El distritoId para la PBA es "02" según la estructura de la API.
|
||||
*/
|
||||
export const getResumenProvincial = async (): Promise<ResumenProvincial> => {
|
||||
// Hardcodeamos el distritoId '02' para Buenos Aires
|
||||
const response = await apiClient.get('/resultados/provincia/02');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBancasPorSeccion = async (seccionId: string): Promise<ProyeccionBancas> => {
|
||||
const response = await apiClient.get(`/resultados/bancas/${seccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
35
Elecciones-Web/frontend/src/components/BancasWidget.css
Normal file
35
Elecciones-Web/frontend/src/components/BancasWidget.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/* src/components/BancasWidget.css */
|
||||
.bancas-widget-container {
|
||||
background-color: #2a2a2e;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.bancas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bancas-header h4 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.bancas-header select {
|
||||
background-color: #3a3a3a;
|
||||
color: white;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.waffle-chart-container {
|
||||
height: 300px;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
@@ -1,52 +1,91 @@
|
||||
// src/components/BancasWidget.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getBancasPorSeccion, type ProyeccionBancas } from '../services/api';
|
||||
import { ResponsiveWaffle } from '@nivo/waffle';
|
||||
import { getBancasPorSeccion } from '../apiService';
|
||||
import type { ProyeccionBancas } from '../types/types';
|
||||
import './BancasWidget.css';
|
||||
|
||||
interface Props {
|
||||
seccionId: string;
|
||||
}
|
||||
// Paleta de colores consistente
|
||||
const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
|
||||
|
||||
export const BancasWidget = ({ seccionId }: Props) => {
|
||||
const [data, setData] = useState<ProyeccionBancas | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Las Secciones Electorales de la Provincia (esto podría venir de la API en el futuro)
|
||||
const secciones = [
|
||||
{ id: '1', nombre: 'Primera Sección' },
|
||||
{ id: '2', nombre: 'Segunda Sección' },
|
||||
{ id: '3', nombre: 'Tercera Sección' },
|
||||
{ id: '4', nombre: 'Cuarta Sección' },
|
||||
{ id: '5', nombre: 'Quinta Sección' },
|
||||
{ id: '6', nombre: 'Sexta Sección' },
|
||||
{ id: '7', nombre: 'Séptima Sección' },
|
||||
{ id: '8', nombre: 'Octava Sección (Capital)' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proyeccion = await getBancasPorSeccion(seccionId);
|
||||
setData(proyeccion);
|
||||
} catch (err) {
|
||||
console.error("Error cargando bancas", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [seccionId]);
|
||||
export const BancasWidget = () => {
|
||||
const [seccionActual, setSeccionActual] = useState('1'); // Empezamos con la Primera Sección
|
||||
const [data, setData] = useState<ProyeccionBancas | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
if (loading) return <div>Cargando proyección de bancas...</div>;
|
||||
if (!data) return <div>No hay datos de bancas disponibles.</div>;
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getBancasPorSeccion(seccionActual);
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error(`Error cargando datos de bancas para sección ${seccionActual}:`, error);
|
||||
setData(null); // Limpiar datos en caso de error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [seccionActual]); // Se ejecuta cada vez que cambia la sección
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
|
||||
<h3>Proyección de Bancas - {data.seccionNombre}</h3>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Agrupación</th>
|
||||
<th style={{ textAlign: 'right' }}>Bancas Obtenidas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.proyeccion.map((partido) => (
|
||||
<tr key={partido.agrupacionNombre}>
|
||||
<td>{partido.agrupacionNombre}</td>
|
||||
<td style={{ textAlign: 'right' }}>{partido.bancas}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
const waffleData = data?.proyeccion.map(p => ({
|
||||
id: p.agrupacionNombre,
|
||||
label: p.agrupacionNombre,
|
||||
value: p.bancas,
|
||||
})) || [];
|
||||
|
||||
const totalBancas = waffleData.reduce((sum, current) => sum + current.value, 0);
|
||||
|
||||
return (
|
||||
<div className="bancas-widget-container">
|
||||
<div className="bancas-header">
|
||||
<h4>Distribución de Bancas</h4>
|
||||
<select value={seccionActual} onChange={e => setSeccionActual(e.target.value)}>
|
||||
{secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="waffle-chart-container">
|
||||
{loading ? <p>Cargando...</p> : !data ? <p>No hay datos disponibles para esta sección.</p> :
|
||||
<ResponsiveWaffle
|
||||
data={waffleData}
|
||||
total={totalBancas}
|
||||
rows={8}
|
||||
columns={10}
|
||||
fillDirection="bottom"
|
||||
padding={3}
|
||||
colors={NIVO_COLORS}
|
||||
borderColor={{ from: 'color', modifiers: [['darker', 0.3]] }}
|
||||
animate={true}
|
||||
legends={[
|
||||
{
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
justify: false,
|
||||
translateX: 0,
|
||||
translateY: 40,
|
||||
itemsSpacing: 4,
|
||||
itemWidth: 100,
|
||||
itemHeight: 20,
|
||||
itemTextColor: '#999',
|
||||
itemDirection: 'left-to-right',
|
||||
symbolSize: 20,
|
||||
},
|
||||
]}
|
||||
/>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
125
Elecciones-Web/frontend/src/components/MapaBsAs.css
Normal file
125
Elecciones-Web/frontend/src/components/MapaBsAs.css
Normal file
@@ -0,0 +1,125 @@
|
||||
/* src/components/MapaBsAs.css */
|
||||
:root {
|
||||
--primary-accent-color: #FF5722;
|
||||
--background-panel-color: #2f2f2f;
|
||||
--border-color: #444;
|
||||
--text-color: #f0f0f0;
|
||||
--text-color-muted: #aaa;
|
||||
--progress-bar-background: #4a4a4a;
|
||||
--scrollbar-thumb-color: #666;
|
||||
--scrollbar-track-color: #333;
|
||||
--map-background-color: #242424; /* Color de fondo del mapa */
|
||||
}
|
||||
|
||||
.mapa-wrapper {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
background-color: var(--map-background-color);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
max-width: 1600px; /* Incrementado para pantallas más grandes */
|
||||
margin: auto;
|
||||
height: 88vh; /* Ligeramente más alto */
|
||||
min-height: 650px;
|
||||
}
|
||||
|
||||
.mapa-container {
|
||||
flex: 0 0 70%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* CORRECCIÓN: Se añade el color de fondo para eliminar el marco blanco */
|
||||
background-color: var(--map-background-color);
|
||||
}
|
||||
|
||||
.mapa-container .rsm-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rsm-geography {
|
||||
transition: opacity 0.3s ease-in-out, transform 0.2s ease-in-out, filter 0.2s ease-in-out, fill 0.3s ease;
|
||||
cursor: pointer;
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.rsm-geography:hover {
|
||||
filter: drop-shadow(0px 0px 6px rgba(255, 255, 255, 0.6));
|
||||
transform: translateY(-1px);
|
||||
stroke-width: 1.5px;
|
||||
}
|
||||
|
||||
.rsm-geography.selected {
|
||||
fill: var(--primary-accent-color); /* Rellena el partido seleccionado con el color principal */
|
||||
stroke: #ffffff; /* Añade un borde blanco para un mejor contraste */
|
||||
stroke-width: 2px; /* Un grosor de borde definido */
|
||||
filter: none; /* Elimina el efecto de sombra/resplandor */
|
||||
outline: none; /* Previene el recuadro de enfoque del navegador */
|
||||
pointer-events: none; /* Mantenemos esto para evitar interacciones no deseadas */
|
||||
}
|
||||
|
||||
.rsm-geography.faded {
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background-color: var(--background-panel-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.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: #888; }
|
||||
.info-panel h3 { margin-top: 0; color: var(--primary-accent-color); border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; }
|
||||
.info-panel p { color: var(--text-color-muted); }
|
||||
|
||||
.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;
|
||||
}
|
||||
.reset-button-panel:hover { background-color: var(--primary-accent-color); color: white; }
|
||||
|
||||
.detalle-placeholder { text-align: center; margin: auto; }
|
||||
.detalle-loading, .detalle-error { text-align: center; margin: auto; color: var(--text-color-muted); }
|
||||
.detalle-metricas { display: flex; justify-content: space-between; font-size: 0.9em; 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: 1rem; }
|
||||
.resultado-info { display: flex; justify-content: space-between; margin-bottom: 0.25rem; font-size: 0.9em; }
|
||||
.partido-nombre { font-weight: 500; }
|
||||
.partido-votos { font-weight: 300; color: var(--text-color-muted); }
|
||||
.progress-bar { height: 8px; background-color: var(--progress-bar-background); border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background-color: var(--primary-accent-color); border-radius: 4px; 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; /* <-- AÑADIDO: Esta línea asegura que los controles estén por encima del mapa. */
|
||||
}
|
||||
.map-controls button {
|
||||
width: 32px; height: 32px; font-size: 1.2rem; font-weight: bold; background-color: rgba(0, 0, 0, 0.7); color: white; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; padding: 0; line-height: 1;
|
||||
}
|
||||
.map-controls button:hover { background-color: rgba(0, 0, 0, 0.9); border-color: var(--primary-accent-color); }
|
||||
|
||||
.legend { margin-top: auto; padding-top: 1rem; border-top: 1px solid var(--border-color); }
|
||||
.legend h4 { margin-top: 0; }
|
||||
.legend-item { display: flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.85em; }
|
||||
.legend-color-box { width: 16px; height: 16px; margin-right: 8px; border-radius: 3px; }
|
||||
264
Elecciones-Web/frontend/src/components/MapaBsAs.tsx
Normal file
264
Elecciones-Web/frontend/src/components/MapaBsAs.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
// src/components/MapaBsAs.tsx
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import type { MouseEvent } 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 type { Feature, Geometry } from 'geojson';
|
||||
import { geoCentroid } from 'd3-geo';
|
||||
|
||||
import './MapaBsAs.css';
|
||||
|
||||
// --- Interfaces y Tipos ---
|
||||
type PointTuple = [number, number];
|
||||
|
||||
interface ResultadoMapa {
|
||||
ambitoId: number;
|
||||
departamentoNombre: string;
|
||||
agrupacionGanadoraId: string;
|
||||
}
|
||||
|
||||
interface ResultadoDetalladoMunicipio {
|
||||
municipioNombre: string;
|
||||
ultimaActualizacion: string;
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: { nombre: string; votos: number; porcentaje: number }[];
|
||||
votosAdicionales: { enBlanco: number; nulos: number; recurridos: number };
|
||||
}
|
||||
|
||||
interface Agrupacion {
|
||||
id: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
interface PartidoProperties {
|
||||
id: string;
|
||||
departamento: string;
|
||||
cabecera: string;
|
||||
provincia: string;
|
||||
}
|
||||
|
||||
type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string };
|
||||
|
||||
// --- Constantes ---
|
||||
const API_BASE_URL = 'http://localhost:5217/api';
|
||||
const COLORES_BASE: string[] = ["#FF5733", "#33FF57", "#3357FF", "#FF33A1", "#A133FF", "#33FFA1", "#FFC300", "#C70039", "#900C3F", "#581845"];
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
// Define los límites del paneo: [[x0, y0], [x1, y1]].
|
||||
// Esto evita que el mapa se "pierda" fuera de la vista.
|
||||
// Estos valores pueden necesitar ajuste fino según el tamaño final del contenedor del mapa.
|
||||
const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -600], [1100, 300]];
|
||||
const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM };
|
||||
|
||||
// --- Componente Principal ---
|
||||
const MapaBsAs = () => {
|
||||
const [position, setPosition] = useState(INITIAL_POSITION);
|
||||
const [selectedAmbitoId, setSelectedAmbitoId] = useState<number | null>(null);
|
||||
|
||||
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({
|
||||
queryKey: ['mapaResultados'],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa`)).data,
|
||||
});
|
||||
const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({
|
||||
queryKey: ['mapaGeoData'],
|
||||
queryFn: async () => (await axios.get('/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, coloresPartidos, resultadosPorDepartamento } = useMemo(() => {
|
||||
const nombresMap = new Map<string, string>();
|
||||
const coloresMap = new Map<string, string>();
|
||||
const resultadosMap = new Map<string, ResultadoMapa>();
|
||||
if (agrupacionesData) {
|
||||
agrupacionesData.forEach((agrupacion, index) => {
|
||||
nombresMap.set(agrupacion.id, agrupacion.nombre);
|
||||
coloresMap.set(agrupacion.id, COLORES_BASE[index % COLORES_BASE.length]);
|
||||
});
|
||||
}
|
||||
coloresMap.set('default', '#D6D6DA');
|
||||
if (resultadosData) {
|
||||
resultadosData.forEach(r => resultadosMap.set(r.departamentoNombre.toUpperCase(), r));
|
||||
}
|
||||
return { nombresAgrupaciones: nombresMap, coloresPartidos: coloresMap, resultadosPorDepartamento: resultadosMap };
|
||||
}, [agrupacionesData, resultadosData]);
|
||||
|
||||
const isLoading = isLoadingResultados || isLoadingAgrupaciones || isLoadingGeo;
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setSelectedAmbitoId(null);
|
||||
setPosition(INITIAL_POSITION);
|
||||
}, []);
|
||||
|
||||
const handleGeographyClick = useCallback((geo: PartidoGeography) => {
|
||||
const departamentoNombre = geo.properties.departamento.toUpperCase();
|
||||
const resultado = resultadosPorDepartamento.get(departamentoNombre);
|
||||
if (!resultado) return;
|
||||
const ambitoIdParaSeleccionar = resultado.ambitoId;
|
||||
if (selectedAmbitoId === ambitoIdParaSeleccionar) {
|
||||
handleReset();
|
||||
} else {
|
||||
const centroid = geoCentroid(geo) as PointTuple;
|
||||
setPosition({ center: centroid, zoom: 5 });
|
||||
setSelectedAmbitoId(ambitoIdParaSeleccionar);
|
||||
}
|
||||
}, [selectedAmbitoId, handleReset, resultadosPorDepartamento]);
|
||||
|
||||
const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => {
|
||||
// La lógica de reseteo cuando se hace zoom out completamente con la rueda del ratón se mantiene.
|
||||
// El `translateExtent` ya previene que el mapa se mueva fuera de los límites.
|
||||
if (newPosition.zoom <= MIN_ZOOM) {
|
||||
if (position.zoom > MIN_ZOOM || selectedAmbitoId !== null) {
|
||||
handleReset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Si se está haciendo zoom out desde una vista detallada, se deselecciona el municipio
|
||||
// para volver a la vista general sin resetear completamente la posición.
|
||||
if (newPosition.zoom < position.zoom && selectedAmbitoId !== null) {
|
||||
setSelectedAmbitoId(null);
|
||||
}
|
||||
|
||||
// Actualiza el estado con la nueva posición y zoom del paneo/zoom del usuario.
|
||||
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) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
// Al presionar el botón de zoom out, siempre se vuelve al estado inicial.
|
||||
handleReset();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset();
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleReset]);
|
||||
|
||||
const getPartyFillColor = (departamentoNombre: string) => {
|
||||
const resultado = resultadosPorDepartamento.get(departamentoNombre.toUpperCase());
|
||||
if (!resultado) return coloresPartidos.get('default') || '#D6D6DA';
|
||||
return coloresPartidos.get(resultado.agrupacionGanadoraId) || coloresPartidos.get('default');
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent<SVGPathElement>) => {
|
||||
const path = e.target as SVGPathElement;
|
||||
if (path.parentNode) {
|
||||
path.parentNode.appendChild(path);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="loading-container">Cargando datos del mapa...</div>;
|
||||
|
||||
return (
|
||||
<div className="mapa-wrapper">
|
||||
<div className="mapa-container">
|
||||
<ComposableMap projection="geoMercator" projectionConfig={{ scale: 4700, center: [-60.5, -37.2] }} className="rsm-svg" style={{ backgroundColor: "#242424" }}>
|
||||
<ZoomableGroup
|
||||
center={position.center}
|
||||
zoom={position.zoom}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
style={{ transition: "transform 400ms ease-in-out" }}
|
||||
translateExtent={TRANSLATE_EXTENT}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
filterZoomEvent={(e: WheelEvent) => {
|
||||
// Detectamos si la rueda se mueve hacia atrás (zoom out)
|
||||
if (e.deltaY > 0) {
|
||||
handleReset();
|
||||
}else if (e.deltaY < 0) {
|
||||
handleZoomIn();
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
{geoData && (
|
||||
<Geographies geography={geoData}>
|
||||
{({ geographies }: { geographies: PartidoGeography[] }) =>
|
||||
geographies.map((geo) => {
|
||||
const departamentoNombre = geo.properties.departamento.toUpperCase();
|
||||
const resultado = resultadosPorDepartamento.get(departamentoNombre);
|
||||
const isSelected = resultado ? selectedAmbitoId === resultado.ambitoId : false;
|
||||
const isFaded = selectedAmbitoId !== null && !isSelected;
|
||||
const nombreAgrupacionGanadora = resultado ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos';
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
data-tooltip-id="partido-tooltip"
|
||||
data-tooltip-content={`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`}
|
||||
className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''}`}
|
||||
fill={getPartyFillColor(geo.properties.departamento)}
|
||||
stroke="#FFF"
|
||||
onClick={() => handleGeographyClick(geo)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
)}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<Tooltip id="partido-tooltip" />
|
||||
<ControlesMapa onZoomIn={handleZoomIn} onZoomOut={handleZoomOut} onReset={handleReset} />
|
||||
</div>
|
||||
<div className="info-panel">
|
||||
<DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} />
|
||||
<Legend colores={coloresPartidos} nombres={nombresAgrupaciones} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-componentes (sin cambios) ---
|
||||
const ControlesMapa = ({ onZoomIn, onZoomOut, onReset }: { onZoomIn: () => void; onZoomOut: () => void; onReset: () => void }) => (
|
||||
<div className="map-controls">
|
||||
<button onClick={onZoomIn}>+</button>
|
||||
<button onClick={onZoomOut}>-</button>
|
||||
<button onClick={onReset}>⌖</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DetalleMunicipio = ({ ambitoId, onReset }: { ambitoId: number | null; onReset: () => void }) => {
|
||||
const { data, isLoading, error } = useQuery<ResultadoDetalladoMunicipio>({
|
||||
queryKey: ['municipioDetalle', ambitoId],
|
||||
queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}`)).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}>← Ver Provincia</button>
|
||||
<h3>{data?.municipioNombre}</h3>
|
||||
<div className="detalle-metricas">
|
||||
<span><strong>Escrutado:</strong> {data?.porcentajeEscrutado.toFixed(2)}%</span>
|
||||
<span><strong>Participación:</strong> {data?.porcentajeParticipacion.toFixed(2)}%</span>
|
||||
</div>
|
||||
<ul className="resultados-lista">{data?.resultados.map(r => (<li key={r.nombre}><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}%` }}></div></div></li>))}</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Legend = ({ colores, nombres }: { colores: Map<string, string>; nombres: Map<string, string> }) => {
|
||||
const legendItems = Array.from(colores.entries()).filter(([id]) => id !== 'default').map(([id, color]) => ({ nombre: nombres.get(id) || 'Desconocido', color: color }));
|
||||
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,112 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
// FIX: Usamos 'import type' para los tipos y quitamos la importación de 'MunicipioSimple'
|
||||
import type { FeatureCollection } from 'geojson';
|
||||
|
||||
// --- Interfaces y Constantes ---
|
||||
interface MapaResultado {
|
||||
municipioId: string;
|
||||
agrupacionGanadoraId: string;
|
||||
}
|
||||
const COLOR_MAP: { [key: string]: string } = { "018": "#FFC107", "025": "#03A9F4", "031": "#4CAF50", "045": "#9C27B0", "default": "#E0E0E0" };
|
||||
|
||||
interface Props {
|
||||
onMunicipioClick: (municipioId: string) => void;
|
||||
}
|
||||
|
||||
export const MapaD3Widget = ({ onMunicipioClick }: Props) => {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; content: string } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const svgElement = svgRef.current;
|
||||
const containerElement = containerRef.current;
|
||||
if (!svgElement || !containerElement) return;
|
||||
|
||||
const drawMap = (
|
||||
geoData: FeatureCollection, // Usamos el tipo correcto
|
||||
resultsMap: Map<string, MapaResultado>,
|
||||
idMap: Record<string, string>
|
||||
) => {
|
||||
const { width, height } = containerElement.getBoundingClientRect();
|
||||
if (width === 0 || height === 0) return;
|
||||
|
||||
const svg = d3.select(svgElement);
|
||||
svg.selectAll('*').remove();
|
||||
svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`);
|
||||
|
||||
const projection = d3.geoMercator().fitSize([width, height], geoData);
|
||||
const pathGenerator = d3.geoPath().projection(projection);
|
||||
|
||||
const features = geoData.features;
|
||||
|
||||
svg.append('g')
|
||||
.selectAll('path')
|
||||
.data(features)
|
||||
.join('path')
|
||||
.attr('d', pathGenerator as any)
|
||||
.attr('stroke', '#FFFFFF')
|
||||
.attr('stroke-width', 0.5)
|
||||
.attr('fill', (d: any) => {
|
||||
const geoJsonId = d.properties.cca;
|
||||
const apiId = idMap[geoJsonId];
|
||||
const resultado = resultsMap.get(apiId);
|
||||
return resultado ? COLOR_MAP[resultado.agrupacionGanadoraId] || COLOR_MAP.default : COLOR_MAP.default;
|
||||
})
|
||||
.style('cursor', 'pointer')
|
||||
.on('click', (_, d: any) => {
|
||||
const apiId = idMap[d.properties.cca];
|
||||
if (apiId) onMunicipioClick(apiId);
|
||||
})
|
||||
.on('mouseover', (event, d: any) => {
|
||||
d3.select(event.currentTarget).attr('stroke', 'black').attr('stroke-width', 2);
|
||||
setTooltip({ x: event.pageX, y: event.pageY, content: d.properties.nam });
|
||||
})
|
||||
.on('mouseout', (event) => {
|
||||
d3.select(event.currentTarget).attr('stroke', '#FFFFFF').attr('stroke-width', 0.5);
|
||||
setTooltip(null);
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [geoData, resultsData, idMap] = await Promise.all([
|
||||
d3.json<FeatureCollection>('/buenos-aires-municipios.geojson'),
|
||||
d3.json<MapaResultado[]>('http://localhost:5217/api/resultados/mapa'),
|
||||
d3.json<Record<string, string>>('/municipioIdMap.json')
|
||||
]);
|
||||
if (geoData && resultsData && idMap) {
|
||||
const resultsMap = new Map(resultsData.map(item => [item.municipioId, item]));
|
||||
drawMap(geoData, resultsMap, idMap);
|
||||
} else {
|
||||
throw new Error("Faltan datos para renderizar el mapa.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error cargando datos para el mapa:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [onMunicipioClick]);
|
||||
|
||||
if (loading) {
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '600px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>Cargando datos del mapa...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', height: '600px', border: '1px solid #eee' }}>
|
||||
<svg ref={svgRef}></svg>
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'fixed', top: tooltip.y + 10, left: tooltip.x + 10,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)', color: 'white', padding: '8px',
|
||||
borderRadius: '4px', pointerEvents: 'none', fontSize: '14px', zIndex: 1000,
|
||||
}}>
|
||||
{tooltip.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import { type MunicipioSimple } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
municipios: MunicipioSimple[];
|
||||
onMunicipioChange: (municipioId: string) => void;
|
||||
}
|
||||
|
||||
export const MunicipioSelector = ({ municipios, onMunicipioChange }: Props) => {
|
||||
return (
|
||||
<select onChange={(e) => onMunicipioChange(e.target.value)} defaultValue="">
|
||||
<option value="" disabled>Seleccione un municipio</option>
|
||||
{municipios.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.nombre}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
// src/components/MunicipioWidget.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getResultadosPorMunicipio, type MunicipioResultados } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
municipioId: string;
|
||||
}
|
||||
|
||||
export const MunicipioWidget = ({ municipioId }: Props) => {
|
||||
const [data, setData] = useState<MunicipioResultados | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resultados = await getResultadosPorMunicipio(municipioId);
|
||||
setData(resultados);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('No se pudieron cargar los datos.');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Hacemos la primera llamada inmediatamente
|
||||
fetchData();
|
||||
|
||||
// Creamos un intervalo para refrescar los datos cada 10 segundos
|
||||
const intervalId = setInterval(fetchData, 10000);
|
||||
|
||||
// ¡Importante! Limpiamos el intervalo cuando el componente se desmonta
|
||||
return () => clearInterval(intervalId);
|
||||
}, [municipioId]); // El efecto se volverá a ejecutar si el municipioId cambia
|
||||
|
||||
if (loading && !data) return <div>Cargando resultados...</div>;
|
||||
if (error) return <div style={{ color: 'red' }}>{error}</div>;
|
||||
if (!data) return <div>No hay datos disponibles.</div>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}>
|
||||
<h2>{data.municipioNombre}</h2>
|
||||
<p>Escrutado: {data.porcentajeEscrutado.toFixed(2)}% | Participación: {data.porcentajeParticipacion.toFixed(2)}%</p>
|
||||
<hr />
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Agrupación</th>
|
||||
<th style={{ textAlign: 'right' }}>Votos</th>
|
||||
<th style={{ textAlign: 'right' }}>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.resultados.map((partido) => (
|
||||
<tr key={partido.nombre}>
|
||||
<td>{partido.nombre}</td>
|
||||
<td style={{ textAlign: 'right' }}>{partido.votos.toLocaleString('es-AR')}</td>
|
||||
<td style={{ textAlign: 'right' }}>{partido.porcentaje.toFixed(2)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style={{fontSize: '0.8em', color: '#666'}}>Última actualización: {new Date(data.ultimaActualizacion).toLocaleTimeString('es-AR')}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getResumenProvincial, type ResumenProvincial } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
distritoId: string;
|
||||
}
|
||||
|
||||
export const ResumenProvincialWidget = ({ distritoId }: Props) => {
|
||||
const [data, setData] = useState<ResumenProvincial | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const resumen = await getResumenProvincial(distritoId);
|
||||
setData(resumen);
|
||||
} catch (err) {
|
||||
console.error("Error cargando resumen provincial", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const intervalId = setInterval(fetchData, 15000); // Actualizamos cada 15s
|
||||
return () => clearInterval(intervalId);
|
||||
}, [distritoId]);
|
||||
|
||||
if (loading) return <div>Cargando resumen provincial...</div>;
|
||||
if (!data) return <div>No hay datos provinciales disponibles.</div>;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px', backgroundColor: '#f9f9f9' }}>
|
||||
<h2>Resumen Provincial - {data.provinciaNombre}</h2>
|
||||
<p><strong>Mesas Escrutadas:</strong> {data.porcentajeEscrutado.toFixed(2)}% | <strong>Participación:</strong> {data.porcentajeParticipacion.toFixed(2)}%</p>
|
||||
|
||||
{data.resultados.map((partido) => (
|
||||
<div key={partido.nombre} style={{ margin: '10px 0' }}>
|
||||
<span>{partido.nombre}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{ backgroundColor: '#ddd', width: '100%', borderRadius: '4px', marginRight: '10px' }}>
|
||||
<div style={{ width: `${partido.porcentaje}%`, backgroundColor: 'royalblue', color: 'white', padding: '4px', borderRadius: '4px', textAlign: 'right' }}>
|
||||
<strong>{partido.porcentaje.toFixed(2)}%</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span>{partido.votos.toLocaleString('es-AR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
// src/components/TelegramasView.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getListaTelegramas, getTelegramaPorId, type TelegramaDetalle } from '../services/api';
|
||||
|
||||
export const TelegramasView = () => {
|
||||
const [listaIds, setListaIds] = useState<string[]>([]);
|
||||
const [selectedTelegrama, setSelectedTelegrama] = useState<TelegramaDetalle | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadList = async () => {
|
||||
try {
|
||||
const ids = await getListaTelegramas();
|
||||
setListaIds(ids);
|
||||
} catch (error) {
|
||||
console.error("Error al cargar lista de telegramas", error);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
loadList();
|
||||
}, []);
|
||||
|
||||
const handleSelectTelegrama = async (mesaId: string) => {
|
||||
try {
|
||||
setLoadingDetail(true);
|
||||
const detalle = await getTelegramaPorId(mesaId);
|
||||
setSelectedTelegrama(detalle);
|
||||
} catch (error) {
|
||||
console.error(`Error al cargar telegrama ${mesaId}`, error);
|
||||
} finally {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '20px', height: '500px' }}>
|
||||
<div style={{ flex: 1, border: '1px solid #ccc', overflowY: 'auto' }}>
|
||||
<h4>Telegramas Disponibles</h4>
|
||||
{loadingList ? <p>Cargando...</p> : (
|
||||
<ul style={{ listStyle: 'none', padding: '10px' }}>
|
||||
{listaIds.map(id => (
|
||||
<li key={id} onClick={() => handleSelectTelegrama(id)} style={{ cursor: 'pointer', padding: '5px', borderBottom: '1px solid #eee' }}>
|
||||
Mesa: {id}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 3, border: '1px solid #ccc', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
{loadingDetail ? <p>Cargando telegrama...</p> :
|
||||
selectedTelegrama ? (
|
||||
<iframe
|
||||
src={`data:application/pdf;base64,${selectedTelegrama.contenidoBase64}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={selectedTelegrama.id}
|
||||
/>
|
||||
) : <p>Seleccione un telegrama de la lista para visualizarlo.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
82
Elecciones-Web/frontend/src/components/TickerWidget.css
Normal file
82
Elecciones-Web/frontend/src/components/TickerWidget.css
Normal file
@@ -0,0 +1,82 @@
|
||||
/* src/components/TickerWidget.css */
|
||||
.ticker-container {
|
||||
background-color: #2a2a2e;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.ticker-container.loading, .ticker-container.error {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
font-style: italic;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ticker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #444;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.ticker-header h3 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.ticker-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ticker-stats strong {
|
||||
color: #a7c7e7;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.ticker-results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.ticker-party .party-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.ticker-party .party-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.ticker-party .party-percent {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.party-bar-background {
|
||||
background-color: #444;
|
||||
border-radius: 4px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.party-bar-foreground {
|
||||
background-color: #646cff;
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
67
Elecciones-Web/frontend/src/components/TickerWidget.tsx
Normal file
67
Elecciones-Web/frontend/src/components/TickerWidget.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
// src/components/TickerWidget.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getResumenProvincial } from '../apiService';
|
||||
import type { ResumenProvincial } from '../types/types';
|
||||
import './TickerWidget.css';
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
|
||||
const COLORS = [
|
||||
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
||||
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"
|
||||
];
|
||||
|
||||
export const TickerWidget = () => {
|
||||
const [data, setData] = useState<ResumenProvincial | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await getResumenProvincial();
|
||||
setData(result);
|
||||
} catch (error) {
|
||||
console.error("Error cargando resumen provincial:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData(); // Carga inicial
|
||||
const intervalId = setInterval(fetchData, 30000); // Actualiza cada 30 segundos
|
||||
|
||||
return () => clearInterval(intervalId); // Limpia el intervalo al desmontar el componente
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="ticker-container loading">Cargando resultados provinciales...</div>;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="ticker-container error">No se pudieron cargar los datos.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ticker-container">
|
||||
<div className="ticker-header">
|
||||
<h3>TOTAL PROVINCIA {data.provinciaNombre}</h3>
|
||||
<div className="ticker-stats">
|
||||
<span>Mesas Escrutadas: <strong>{formatPercent(data.porcentajeEscrutado)}</strong></span>
|
||||
<span>Participación: <strong>{formatPercent(data.porcentajeParticipacion)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ticker-results">
|
||||
{data.resultados.slice(0, 3).map((partido, index) => (
|
||||
<div key={`${partido.nombre}-${index}`} className="ticker-party">
|
||||
<div className="party-info">
|
||||
<span className="party-name">{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:COLORS[index % COLORS.length] }}></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
/* src/index.css */
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
/* Tema Claro por defecto */
|
||||
color-scheme: light;
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -42,7 +44,8 @@ button {
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
/* Color de fondo para botones en tema claro */
|
||||
background-color: #f9f9f9;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
@@ -52,17 +55,4 @@ button:hover {
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
// src/main.tsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
// Crear un cliente de React Query
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,89 +0,0 @@
|
||||
// src/services/api.ts
|
||||
import axios from 'axios';
|
||||
|
||||
// Creamos una instancia de Axios.
|
||||
// OJO: Usamos el puerto del PROXY (8600) que configuramos en docker-compose.yml
|
||||
// No usamos el puerto de la API de .NET directamente.
|
||||
const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:5217/api'
|
||||
});
|
||||
|
||||
// Definimos las interfaces de TypeScript que coinciden con los DTOs de nuestra API.
|
||||
export interface AgrupacionResultado {
|
||||
nombre: string;
|
||||
votos: number;
|
||||
porcentaje: number;
|
||||
}
|
||||
|
||||
export interface VotosAdicionales {
|
||||
enBlanco: number;
|
||||
nulos: number;
|
||||
recurridos: number;
|
||||
}
|
||||
|
||||
export interface MunicipioResultados {
|
||||
municipioNombre: string;
|
||||
ultimaActualizacion: string; // La fecha viene como string
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: AgrupacionResultado[];
|
||||
votosAdicionales: VotosAdicionales;
|
||||
}
|
||||
|
||||
export interface MunicipioSimple {
|
||||
id: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
export interface ResumenProvincial extends Omit<MunicipioResultados, 'municipioNombre'> {
|
||||
provinciaNombre: string;
|
||||
}
|
||||
|
||||
export interface BancaResultado {
|
||||
agrupacionNombre: string;
|
||||
bancas: number;
|
||||
}
|
||||
|
||||
export interface ProyeccionBancas {
|
||||
seccionNombre: string;
|
||||
proyeccion: BancaResultado[];
|
||||
}
|
||||
|
||||
export interface TelegramaDetalle {
|
||||
id: string;
|
||||
ambitoGeograficoId: number;
|
||||
contenidoBase64: string;
|
||||
fechaEscaneo: string;
|
||||
fechaTotalizacion: string;
|
||||
}
|
||||
|
||||
export const getBancasPorSeccion = async (seccionId: string): Promise<ProyeccionBancas> => {
|
||||
const response = await apiClient.get<ProyeccionBancas>(`/resultados/bancas/${seccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getListaTelegramas = async (): Promise<string[]> => {
|
||||
const response = await apiClient.get<string[]>('/telegramas');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getTelegramaPorId = async (mesaId: string): Promise<TelegramaDetalle> => {
|
||||
const response = await apiClient.get<TelegramaDetalle>(`/telegramas/${mesaId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getMunicipios = async (): Promise<MunicipioSimple[]> => {
|
||||
const response = await apiClient.get<MunicipioSimple[]>('/catalogos/municipios');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getResumenProvincial = async (distritoId: string): Promise<ResumenProvincial> => {
|
||||
const response = await apiClient.get<ResumenProvincial>(`/resultados/provincia/${distritoId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Función para obtener los resultados de un municipio
|
||||
export const getResultadosPorMunicipio = async (municipioId: string): Promise<MunicipioResultados> => {
|
||||
const response = await apiClient.get<MunicipioResultados>(`/resultados/municipio/${municipioId}`);
|
||||
return response.data;
|
||||
};
|
||||
11
Elecciones-Web/frontend/src/types/custom.d.ts
vendored
Normal file
11
Elecciones-Web/frontend/src/types/custom.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
// src/types/custom.d.ts
|
||||
|
||||
// Le dice a TypeScript: "Confía en mí, el módulo 'react-simple-maps' existe.
|
||||
// No te preocupes por sus tipos internos, yo me encargo."
|
||||
declare module 'react-simple-maps';
|
||||
|
||||
// Esta declaración ya debería estar aquí.
|
||||
declare module '*.topojson' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
||||
62
Elecciones-Web/frontend/src/types/types.ts
Normal file
62
Elecciones-Web/frontend/src/types/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/types/types.ts
|
||||
|
||||
// Tipos para la respuesta de la API de resultados por municipio
|
||||
export interface AgrupacionResultadoDto {
|
||||
nombre: string;
|
||||
votos: number;
|
||||
porcentaje: number;
|
||||
}
|
||||
|
||||
export interface VotosAdicionalesDto {
|
||||
enBlanco: number;
|
||||
nulos: number;
|
||||
recorridos: number;
|
||||
}
|
||||
|
||||
export interface MunicipioResultadosDto {
|
||||
municipioNombre: string;
|
||||
ultimaActualizacion: string;
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: AgrupacionResultadoDto[];
|
||||
votosAdicionales: VotosAdicionalesDto;
|
||||
}
|
||||
|
||||
// Tipo para la respuesta del endpoint del mapa
|
||||
export interface MapaDto {
|
||||
ambitoId: number;
|
||||
departamentoNombre: string;
|
||||
agrupacionGanadoraId: string;
|
||||
}
|
||||
|
||||
// Definición de tipo para los objetos de geografía de react-simple-maps
|
||||
export interface GeographyObject {
|
||||
rsmKey: string;
|
||||
properties: {
|
||||
// CORRECCIÓN: Se cambia 'nombre' por 'NAME_2' para coincidir con el archivo topojson
|
||||
NAME_2: string;
|
||||
[key: string]: any; // Permite otras propiedades que puedan venir
|
||||
};
|
||||
}
|
||||
|
||||
export interface MunicipioSimple { id: string; nombre: string; }
|
||||
export interface AgrupacionResultado { nombre: string; votos: number; porcentaje: number; }
|
||||
export interface VotosAdicionales { enBlanco: number; nulos: number; recurridos: number; }
|
||||
export interface MunicipioDetalle {
|
||||
municipioNombre: string;
|
||||
ultimaActualizacion: string;
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: AgrupacionResultado[];
|
||||
votosAdicionales: VotosAdicionales;
|
||||
}
|
||||
export interface ResumenProvincial {
|
||||
provinciaNombre: string;
|
||||
ultimaActualizacion: string;
|
||||
porcentajeEscrutado: number;
|
||||
porcentajeParticipacion: number;
|
||||
resultados: AgrupacionResultado[];
|
||||
votosAdicionales: VotosAdicionales;
|
||||
}
|
||||
export interface Banca { agrupacionNombre: string; bancas: number; }
|
||||
export interface ProyeccionBancas { seccionNombre: string; proyeccion: Banca[]; }
|
||||
Reference in New Issue
Block a user