Test Docker
This commit is contained in:
@@ -40,3 +40,7 @@
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -1,28 +1,100 @@
|
||||
// src/App.tsx
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { MunicipioWidget } from './components/MunicipioWidget';
|
||||
import { MunicipioSelector } from './components/MunicipioSelector';
|
||||
import { getMunicipios, type MunicipioSimple } from './services/api';
|
||||
import './App.css';
|
||||
import { ResumenProvincialWidget } from './components/ResumenProvincialWidget';
|
||||
import { BancasWidget } from './components/BancasWidget';
|
||||
import { TelegramasView } from './components/TelegramasView';
|
||||
import { MapaD3Widget } from './components/MapaD3Widget';
|
||||
|
||||
function App() {
|
||||
const [selectedMunicipioId, setSelectedMunicipioId] = useState<string | null>(null);
|
||||
const [listaMunicipios, setListaMunicipios] = useState<MunicipioSimple[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getMunicipios()
|
||||
.then(setListaMunicipios)
|
||||
.catch(err => console.error("Error al cargar la lista de municipios", err))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <h1>Cargando datos iniciales...</h1>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Elecciones 2025 - Resultados en Vivo</h1>
|
||||
|
||||
{/* Aquí podrías poner el widget del Resumen Provincial */}
|
||||
<section>
|
||||
<ResumenProvincialWidget distritoId="02" />
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Consulta por Municipio</h2>
|
||||
<MunicipioSelector onMunicipioChange={setSelectedMunicipioId} />
|
||||
|
||||
{selectedMunicipioId && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<MunicipioWidget municipioId={selectedMunicipioId} />
|
||||
<section style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '20px' }}>
|
||||
<div>
|
||||
<h2>Mapa de Resultados</h2>
|
||||
<MapaD3Widget
|
||||
municipios={listaMunicipios}
|
||||
onMunicipioClick={setSelectedMunicipioId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2>Consulta por Municipio</h2>
|
||||
<MunicipioSelector
|
||||
municipios={listaMunicipios}
|
||||
onMunicipioChange={setSelectedMunicipioId}
|
||||
/>
|
||||
{selectedMunicipioId && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<MunicipioWidget municipioId={selectedMunicipioId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
52
Elecciones-Web/frontend/src/components/BancasWidget.tsx
Normal file
52
Elecciones-Web/frontend/src/components/BancasWidget.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
// src/components/BancasWidget.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getBancasPorSeccion, type ProyeccionBancas } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
seccionId: string;
|
||||
}
|
||||
|
||||
export const BancasWidget = ({ seccionId }: Props) => {
|
||||
const [data, setData] = useState<ProyeccionBancas | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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]);
|
||||
|
||||
if (loading) return <div>Cargando proyección de bancas...</div>;
|
||||
if (!data) return <div>No hay datos de bancas disponibles.</div>;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
112
Elecciones-Web/frontend/src/components/MapaD3Widget.tsx
Normal file
112
Elecciones-Web/frontend/src/components/MapaD3Widget.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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,31 +1,11 @@
|
||||
// src/components/MunicipioSelector.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getMunicipios, type MunicipioSimple } from '../services/api';
|
||||
import { type MunicipioSimple } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
municipios: MunicipioSimple[];
|
||||
onMunicipioChange: (municipioId: string) => void;
|
||||
}
|
||||
|
||||
export const MunicipioSelector = ({ onMunicipioChange }: Props) => {
|
||||
const [municipios, setMunicipios] = useState<MunicipioSimple[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMunicipios = async () => {
|
||||
try {
|
||||
const data = await getMunicipios();
|
||||
setMunicipios(data);
|
||||
} catch (error) {
|
||||
console.error("Error al cargar municipios", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadMunicipios();
|
||||
}, []);
|
||||
|
||||
if (loading) return <p>Cargando municipios...</p>;
|
||||
|
||||
export const MunicipioSelector = ({ municipios, onMunicipioChange }: Props) => {
|
||||
return (
|
||||
<select onChange={(e) => onMunicipioChange(e.target.value)} defaultValue="">
|
||||
<option value="" disabled>Seleccione un municipio</option>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
65
Elecciones-Web/frontend/src/components/TelegramasView.tsx
Normal file
65
Elecciones-Web/frontend/src/components/TelegramasView.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
@@ -39,6 +39,39 @@ export interface ResumenProvincial extends Omit<MunicipioResultados, 'municipioN
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user