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:
2025-08-14 15:27:45 -03:00
parent b90baadeed
commit 1d58023113
70 changed files with 5563 additions and 89 deletions

24
Elecciones-Web/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3661
Elecciones-Web/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.11.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

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

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

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

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

View 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>,
)

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

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})