Feat: Implementar API de resultados y widget de prueba dinámico con selector
API (Backend):
Se crea el endpoint GET /api/resultados/municipio/{id} para servir los resultados detallados de un municipio específico.
Se añade el endpoint GET /api/catalogos/municipios para poblar selectores en el frontend.
Se incluye un endpoint simulado GET /api/resultados/provincia/{id} para facilitar el desarrollo futuro del frontend.
Worker (Servicio de Ingesta):
La lógica de sondeo se ha hecho dinámica. Ahora consulta todos los municipios presentes en la base de datos en lugar de uno solo.
El servicio falso (FakeElectoralApiService) se ha mejorado para generar datos aleatorios para cualquier municipio solicitado.
Frontend (React):
Se crea el componente <MunicipioSelector /> que se carga con datos desde la nueva API de catálogos.
Se integra el selector en la página principal, permitiendo al usuario elegir un municipio.
El componente <MunicipioWidget /> ahora recibe el ID del municipio como una prop y muestra los datos del municipio seleccionado, actualizándose en tiempo real.
Configuración:
Se ajusta la política de CORS en la API para permitir peticiones desde el servidor de desarrollo de Vite (localhost:5173), solucionando errores de conexión en el entorno local.
This commit is contained in:
42
Elecciones-Web/frontend/src/App.css
Normal file
42
Elecciones-Web/frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
30
Elecciones-Web/frontend/src/App.tsx
Normal file
30
Elecciones-Web/frontend/src/App.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
// src/App.tsx
|
||||
import { useState } from 'react';
|
||||
import { MunicipioWidget } from './components/MunicipioWidget';
|
||||
import { MunicipioSelector } from './components/MunicipioSelector';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [selectedMunicipioId, setSelectedMunicipioId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Elecciones 2025 - Resultados en Vivo</h1>
|
||||
|
||||
{/* Aquí podrías poner el widget del Resumen Provincial */}
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Consulta por Municipio</h2>
|
||||
<MunicipioSelector onMunicipioChange={setSelectedMunicipioId} />
|
||||
|
||||
{selectedMunicipioId && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<MunicipioWidget municipioId={selectedMunicipioId} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
37
Elecciones-Web/frontend/src/components/MunicipioSelector.tsx
Normal file
37
Elecciones-Web/frontend/src/components/MunicipioSelector.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
// src/components/MunicipioSelector.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getMunicipios, type MunicipioSimple } from '../services/api';
|
||||
|
||||
interface Props {
|
||||
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>;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
69
Elecciones-Web/frontend/src/components/MunicipioWidget.tsx
Normal file
69
Elecciones-Web/frontend/src/components/MunicipioWidget.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
68
Elecciones-Web/frontend/src/index.css
Normal file
68
Elecciones-Web/frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
: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;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
10
Elecciones-Web/frontend/src/main.tsx
Normal file
10
Elecciones-Web/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
56
Elecciones-Web/frontend/src/services/api.ts
Normal file
56
Elecciones-Web/frontend/src/services/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// 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 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;
|
||||
};
|
||||
1
Elecciones-Web/frontend/src/vite-env.d.ts
vendored
Normal file
1
Elecciones-Web/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user