Test Docker

This commit is contained in:
2025-08-15 17:31:51 -03:00
parent 39b1e97072
commit bce5b1dcec
97 changed files with 8493 additions and 216 deletions

View File

@@ -40,3 +40,7 @@
.read-the-docs {
color: #888;
}
section {
margin-bottom: 2rem;
}

View File

@@ -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>
</>
);
}

View 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>
);
};

View 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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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;