Feat Front Widgets Refactizados y Ajustes Backend

This commit is contained in:
2025-08-22 21:55:03 -03:00
parent 18e6e8d3c0
commit 5de9d6729c
54 changed files with 2443 additions and 1680 deletions

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,18 @@
// src/App.tsx
import 'react-tooltip/dist/react-tooltip.css';
import MapaBsAs from './components/MapaBsAs';
function App() {
return (
<div style={{ width: '50%', margin: '2rem auto' }}>
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
<p>Pasa el ratón sobre un partido para ver el ganador. Haz clic para más detalles.</p>
<MapaBsAs /> {/* Ya no necesita un div contenedor aquí */}
{/* Próximo paso: Crear una Leyenda aquí */}
</div>
);
}
export default App;

View File

@@ -0,0 +1,58 @@
/* src/components/MapaBsAs.css */
.mapa-wrapper {
display: flex;
gap: 2rem;
}
.mapa-container {
flex: 3; /* El mapa ocupa 3/4 del espacio */
border: 1px solid #ccc;
border-radius: 8px;
position: relative;
/* Proporción aproximada para la provincia de Bs As */
padding-top: 80%;
}
.mapa-container .rsm-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
/* Efecto "lift" al pasar el ratón */
.rsm-geography {
transition: transform 0.2s ease-in-out, fill 0.2s ease-in-out;
cursor: pointer;
}
.rsm-geography:hover {
transform: scale(1.03);
transform-origin: center center;
}
.info-panel {
flex: 1; /* El panel ocupa 1/4 del espacio */
padding: 1rem;
background-color: #f9f9f9;
border-radius: 8px;
}
.info-panel h3 {
margin-top: 0;
}
.legend {
margin-top: 1.5rem;
}
.legend-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
}
.legend-color-box {
width: 20px;
height: 20px;
margin-right: 10px;
border: 1px solid #fff;
outline: 1px solid #ccc;
}

View File

@@ -0,0 +1,217 @@
// src/components/MapaBsAs.tsx
import { useState, useMemo } from 'react';
import { ComposableMap, Geographies, Geography } 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'; // Para calcular el centro de cada partido
import { useSpring, animated } from 'react-spring'; // Para animar el zoom
//import geoUrl from '/partidos-bsas.topojson';
import './MapaBsAs.css';
// --- Interfaces y Tipos ---
interface ResultadoMapa {
partidoId: string;
agrupacionGanadoraId: string;
porcentajeGanador: number; // Nueva propiedad desde el backend
}
interface Agrupacion {
id: string;
nombre: string;
}
interface PartidoProperties {
id: number;
departamento: string;
cabecera: string; // Asegúrate de que coincida con tu topojson
provincia: string;
}
type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string };
const PALETA_COLORES: { [key: string]: [number, number, number] } = {
'default': [214, 214, 218] // RGB para el color por defecto
};
const INITIAL_PROJECTION = {
center: [-59.8, -37.0] as [number, number],
scale: 5400,
};
const MapaBsAs = () => {
const [selectedPartido, setSelectedPartido] = useState<PartidoGeography | null>(null);
const [projectionConfig, setProjectionConfig] = useState(INITIAL_PROJECTION);
// --- Carga de Datos ---
const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({
queryKey: ['mapaResultados'],
queryFn: async () => {
const { data } = await axios.get('http://localhost:5217/api/Resultados/mapa');
return data;
},
});
const { data: geoData, isLoading: isLoadingGeo } = useQuery({
queryKey: ['mapaGeoData'],
queryFn: async () => {
const { data } = await axios.get('/partidos-bsas.topojson');
return data;
},
});
const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({
queryKey: ['catalogoAgrupaciones'],
queryFn: async () => {
const { data } = await axios.get('http://localhost:5217/api/Catalogos/agrupaciones');
return data;
},
});
const { nombresAgrupaciones, coloresPartidos } = useMemo(() => {
if (!agrupacionesData) return { nombresAgrupaciones: {}, coloresPartidos: {} };
const nombres = agrupacionesData.reduce((acc, agrupacion) => {
acc[agrupacion.id] = agrupacion.nombre;
return acc;
}, {} as { [key: string]: string });
const colores = agrupacionesData.reduce((acc, agrupacion, index) => {
const baseColor = [255, 87, 51, 51, 255, 87, 51, 87, 255, 255, 51, 161, 161, 51, 255, 255, 195, 0, 199, 0, 57, 144, 12, 63, 88, 24, 69];
acc[agrupacion.nombre] = [baseColor[index*3], baseColor[index*3+1], baseColor[index*3+2]];
return acc;
}, {} as { [key: string]: [number, number, number] });
colores['default'] = [214, 214, 218];
return { nombresAgrupaciones: nombres, coloresPartidos: colores };
}, [agrupacionesData]);
const animatedProps = useSpring({
to: { scale: projectionConfig.scale, cx: projectionConfig.center[0], cy: projectionConfig.center[1] },
config: { tension: 170, friction: 26 },
});
if (isLoadingResultados || isLoadingGeo || isLoadingAgrupaciones) {
return <div>Cargando datos del mapa...</div>;
}
const getPartyStyle = (partidoIdGeo: string) => {
const resultado = resultadosData?.find(r => r.partidoId === partidoIdGeo);
if (!resultado) {
return { fill: `rgb(${PALETA_COLORES.default.join(',')})` };
}
const nombreAgrupacion = nombresAgrupaciones[resultado.agrupacionGanadoraId] || 'Otro';
const baseColor = coloresPartidos[nombreAgrupacion] || PALETA_COLORES.default;
// Calcula la opacidad basada en el porcentaje. 0.4 (débil) a 1.0 (fuerte)
const opacity = 0.4 + (resultado.porcentajeGanador / 100) * 0.6;
return { fill: `rgba(${baseColor.join(',')}, ${opacity})` };
};
const handleGeographyClick = (geo: PartidoGeography) => {
const centroid = geoCentroid(geo);
setSelectedPartido(geo);
setProjectionConfig({
center: centroid,
scale: 18000, // Zoom más cercano
});
};
const handleReset = () => {
setSelectedPartido(null);
setProjectionConfig(INITIAL_PROJECTION);
};
return (
<div className="mapa-wrapper">
<div className="mapa-container">
<animated.div style={{
width: '100%',
height: '100%',
transform: animatedProps.scale.to(s => `scale(${s / INITIAL_PROJECTION.scale})`),
}}>
<ComposableMap
projection="geoMercator"
projectionConfig={{
scale: animatedProps.scale,
center: [animatedProps.cx, animatedProps.cy],
}}
className="rsm-svg"
>
<Geographies geography={geoData}>
{({ geographies }: { geographies: PartidoGeography[] }) =>
geographies.map((geo) => {
const partidoId = String(geo.properties.id);
const partidoNombre = geo.properties.departamento;
const resultado = resultadosData?.find(r => r.partidoId === partidoId);
const agrupacionNombre = resultado ? (nombresAgrupaciones[resultado.agrupacionGanadoraId] || 'Desconocido') : 'Sin datos';
return (
<Geography
key={geo.rsmKey}
geography={geo}
data-tooltip-id="partido-tooltip"
data-tooltip-content={`${partidoNombre}: ${agrupacionNombre}`}
fill={getPartyStyle(partidoId).fill}
stroke="#FFF"
className="rsm-geography"
style={{
default: { outline: 'none' },
hover: { outline: 'none', stroke: '#FF5722', strokeWidth: 2, fill: getPartyStyle(partidoId).fill },
pressed: { outline: 'none' },
}}
onClick={() => handleGeographyClick(geo)}
/>
);
})
}
</Geographies>
</ComposableMap>
</animated.div>
<Tooltip id="partido-tooltip" />
</div>
<div className="info-panel">
<button onClick={handleReset}>Resetear Vista</button>
{selectedPartido ? (
<div>
<h3>{selectedPartido.properties.departamento}</h3>
<p><strong>Cabecera:</strong> {selectedPartido.properties.cabecera}</p>
<p><strong>ID:</strong> {selectedPartido.properties.id}</p>
{/* Aquí mostrarías más datos del partido seleccionado */}
</div>
) : (
<div>
<h3>Provincia de Buenos Aires</h3>
<p>Selecciona un partido para ver más detalles.</p>
</div>
)}
<Legend colores={coloresPartidos} />
</div>
</div>
);
};
// Componente de Leyenda separado
const Legend = ({ colores }: { colores: { [key: string]: [number, number, number] } }) => {
return (
<div className="legend">
<h4>Leyenda</h4>
{Object.entries(colores).map(([nombre, color]) => {
if (nombre === 'default') return null;
return (
<div key={nombre} className="legend-item">
<div className="legend-color-box" style={{ backgroundColor: `rgb(${color.join(',')})` }} />
<span>{nombre}</span>
</div>
);
})}
</div>
);
};
export default MapaBsAs;

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,17 @@
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Crea un cliente
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
// src/types/custom.d.ts
// Solución para el problema 1: Le dice a TypeScript que el módulo 'react-simple-maps' existe.
// Esto evita el error de "módulo no encontrado" y trata sus componentes como 'any'.
declare module 'react-simple-maps';
// Solución para el problema 2: Le dice a TypeScript cómo manejar la importación de archivos .topojson.
// Define que cuando importemos un archivo con esa extensión, el contenido será de tipo 'any'.
declare module '*.topojson' {
const value: any;
export default value;
}

View File

@@ -0,0 +1,56 @@
// src/apiService.ts
import axios from 'axios';
import type { MunicipioSimple, MunicipioDetalle, ResumenProvincial, ProyeccionBancas } from './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;
};

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

View File

@@ -0,0 +1,91 @@
// src/components/BancasWidget.tsx
import { useState, useEffect } from 'react';
import { ResponsiveWaffle } from '@nivo/waffle';
import { getBancasPorSeccion } from '../apiService';
import type { ProyeccionBancas } from '../types';
import './BancasWidget.css';
// Paleta de colores consistente
const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
// 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)' },
];
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);
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
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>
);
};

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

View File

@@ -0,0 +1,67 @@
// src/components/TickerWidget.tsx
import { useState, useEffect } from 'react';
import { getResumenProvincial } from '../apiService';
import type { ResumenProvincial } from '../types';
import './TickerWidget.css';
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
const NIVO_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>{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"> {/* <-- CAMBIO AQUÍ */}
<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: NIVO_COLORS[index % NIVO_COLORS.length] }}></div>
</div>
</div>
))}
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -10,14 +10,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@nivo/waffle": "^0.99.0",
"@tanstack/react-query": "^5.85.5",
"@types/d3-geo": "^3.1.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"d3": "^7.9.0", "d3-geo": "^3.1.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1",
"react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support",
"react-tooltip": "^5.29.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@types/d3": "^7.4.3",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",

File diff suppressed because one or more lines are too long

View File

@@ -1,145 +0,0 @@
{
"100": "100",
"101": "101",
"102": "102",
"103": "103",
"104": "104",
"105": "105",
"106": "106",
"107": "107",
"108": "108",
"109": "109",
"110": "110",
"111": "111",
"113": "113",
"114": "114",
"115": "115",
"116": "116",
"117": "117",
"118": "118",
"119": "119",
"120": "120",
"121": "121",
"122": "122",
"123": "123",
"124": "124",
"125": "125",
"126": "126",
"127": "127",
"128": "128",
"129": "129",
"130": "130",
"131": "131",
"132": "132",
"133": "133",
"134": "134",
"135": "135",
"136": "136",
"137": "137",
"309": "309",
"314": "314",
"338": "338",
"357": "357",
"387": "387",
"396": "396",
"398": "398",
"399": "399",
"045": "045",
"055": "055",
"070": "070",
"030": "030",
"074": "074",
"003": "003",
"086": "086",
"082": "082",
"063": "063",
"006": "006",
"047": "047",
"084": "084",
"026": "026",
"025": "025",
"007": "007",
"033": "033",
"076": "076",
"054": "054",
"072": "072",
"012": "012",
"004": "004",
"077": "077",
"087": "087",
"098": "098",
"015": "015",
"080": "080",
"046": "046",
"038": "038",
"032": "032",
"014": "014",
"064": "064",
"083": "083",
"049": "049",
"058": "058",
"023": "023",
"060": "060",
"018": "018",
"042": "042",
"062": "062",
"066": "066",
"041": "041",
"053": "053",
"057": "057",
"001": "001",
"005": "005",
"002": "002",
"031": "031",
"008": "008",
"009": "009",
"013": "013",
"010": "010",
"011": "011",
"019": "019",
"017": "017",
"097": "097",
"020": "020",
"016": "016",
"037": "037",
"021": "021",
"027": "027",
"022": "022",
"036": "036",
"029": "029",
"028": "028",
"035": "035",
"056": "056",
"044": "044",
"034": "034",
"040": "040",
"039": "039",
"043": "043",
"051": "051",
"052": "052",
"059": "059",
"065": "065",
"061": "061",
"024": "024",
"068": "068",
"050": "050",
"078": "078",
"079": "079",
"067": "067",
"096": "096",
"099": "099",
"075": "075",
"081": "081",
"069": "069",
"073": "073",
"089": "089",
"090": "090",
"085": "085",
"088": "088",
"091": "091",
"093": "093",
"071": "071",
"095": "095",
"092": "092",
"094": "094"
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@@ -1,69 +1,20 @@
// src/App.tsx // src/App.tsx
import { useState } from 'react'; import './App.css'
import { MunicipioWidget } from './components/MunicipioWidget'; //import { BancasWidget } from './components/BancasWidget'
import { MunicipioSelector } from './components/MunicipioSelector'; import MapaBsAs from './components/MapaBsAs'
import './App.css'; import { TickerWidget } from './components/TickerWidget'
import { ResumenProvincialWidget } from './components/ResumenProvincialWidget';
import { BancasWidget } from './components/BancasWidget';
import { TelegramasView } from './components/TelegramasView';
function App() { function App() {
const [selectedMunicipioId, setSelectedMunicipioId] = useState<string | null>(null);
return ( return (
<> <>
<h1>Elecciones 2025 - Resultados en Vivo</h1> <h1>Resultados Electorales - Provincia de Buenos Aires</h1>
<section> <main>
<ResumenProvincialWidget distritoId="02" /> <TickerWidget />
</section> {/*<BancasWidget />*/}
<MapaBsAs />
<hr /> </main>
<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>
</> </>
); )
} }
export default App; export default App

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

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

View File

@@ -1,52 +1,91 @@
// src/components/BancasWidget.tsx // src/components/BancasWidget.tsx
import { useState, useEffect } from 'react'; 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 { // Paleta de colores consistente
seccionId: string; const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
}
export const BancasWidget = ({ seccionId }: Props) => { // Las Secciones Electorales de la Provincia (esto podría venir de la API en el futuro)
const [data, setData] = useState<ProyeccionBancas | null>(null); const secciones = [
const [loading, setLoading] = useState(true); { 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(() => { export const BancasWidget = () => {
const fetchData = async () => { const [seccionActual, setSeccionActual] = useState('1'); // Empezamos con la Primera Sección
try { const [data, setData] = useState<ProyeccionBancas | null>(null);
setLoading(true); const [loading, setLoading] = useState(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>; useEffect(() => {
if (!data) return <div>No hay datos de bancas disponibles.</div>; 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 ( const waffleData = data?.proyeccion.map(p => ({
<div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}> id: p.agrupacionNombre,
<h3>Proyección de Bancas - {data.seccionNombre}</h3> label: p.agrupacionNombre,
<table style={{ width: '100%', borderCollapse: 'collapse' }}> value: p.bancas,
<thead> })) || [];
<tr>
<th style={{ textAlign: 'left' }}>Agrupación</th> const totalBancas = waffleData.reduce((sum, current) => sum + current.value, 0);
<th style={{ textAlign: 'right' }}>Bancas Obtenidas</th>
</tr> return (
</thead> <div className="bancas-widget-container">
<tbody> <div className="bancas-header">
{data.proyeccion.map((partido) => ( <h4>Distribución de Bancas</h4>
<tr key={partido.agrupacionNombre}> <select value={seccionActual} onChange={e => setSeccionActual(e.target.value)}>
<td>{partido.agrupacionNombre}</td> {secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}
<td style={{ textAlign: 'right' }}>{partido.bancas}</td> </select>
</tr> </div>
))} <div className="waffle-chart-container">
</tbody> {loading ? <p>Cargando...</p> : !data ? <p>No hay datos disponibles para esta sección.</p> :
</table> <ResponsiveWaffle
</div> 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>
);
}; };

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,11 +1,13 @@
/* src/index.css */
:root { :root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark; /* Tema Claro por defecto */
color: rgba(255, 255, 255, 0.87); color-scheme: light;
background-color: #242424; color: #213547;
background-color: #ffffff;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
@@ -42,7 +44,8 @@ button {
font-size: 1em; font-size: 1em;
font-weight: 500; font-weight: 500;
font-family: inherit; font-family: inherit;
background-color: #1a1a1a; /* Color de fondo para botones en tema claro */
background-color: #f9f9f9;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s; transition: border-color 0.25s;
} }
@@ -53,16 +56,3 @@ button:focus,
button:focus-visible { button:focus-visible {
outline: 4px auto -webkit-focus-ring-color; 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

@@ -1,10 +1,17 @@
import { StrictMode } from 'react' // src/main.tsx
import { createRoot } from 'react-dom/client' import React from 'react'
import './index.css' import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx' import App from './App.tsx'
import './index.css'
createRoot(document.getElementById('root')!).render( // Crear un cliente de React Query
<StrictMode> const queryClient = new QueryClient()
<App />
</StrictMode>, ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
) )

View File

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

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

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

View File

@@ -21,13 +21,13 @@ public class CatalogosController : ControllerBase
[HttpGet("municipios")] [HttpGet("municipios")]
public async Task<IActionResult> GetMunicipios() public async Task<IActionResult> GetMunicipios()
{ {
// El NivelId 5 corresponde a "Municipio" según los datos que hemos visto. // CORRECCIÓN: Los partidos/municipios corresponden al NivelId 30 (Sección)
var municipios = await _dbContext.AmbitosGeograficos var municipios = await _dbContext.AmbitosGeograficos
.AsNoTracking() .AsNoTracking()
.Where(a => a.NivelId == 5 && a.MunicipioId != null) .Where(a => a.NivelId == 30 && a.SeccionId != null) // <-- NivelId 30
.Select(a => new MunicipioSimpleDto .Select(a => new MunicipioSimpleDto
{ {
Id = a.MunicipioId!, Id = a.SeccionId!, // <-- Usamos SeccionId como el ID
Nombre = a.Nombre Nombre = a.Nombre
}) })
.OrderBy(m => m.Nombre) .OrderBy(m => m.Nombre)
@@ -35,4 +35,14 @@ public class CatalogosController : ControllerBase
return Ok(municipios); return Ok(municipios);
} }
[HttpGet("agrupaciones")]
public async Task<IActionResult> GetAgrupaciones()
{
var agrupaciones = await _dbContext.AgrupacionesPoliticas
.AsNoTracking()
.Select(a => new { a.Id, a.Nombre }) // Devuelve solo lo necesario
.ToListAsync();
return Ok(agrupaciones);
}
} }

View File

@@ -21,17 +21,18 @@ public class ResultadosController : ControllerBase
_logger = logger; _logger = logger;
} }
[HttpGet("municipio/{municipioId}")] [HttpGet("partido/{seccionId}")]
public async Task<IActionResult> GetResultadosPorMunicipio(string municipioId) public async Task<IActionResult> GetResultadosPorPartido(string seccionId)
{ {
// 1. Buscamos el ámbito geográfico correspondiente al municipio // 1. Buscamos el ámbito geográfico correspondiente al PARTIDO (Nivel 30)
var ambito = await _dbContext.AmbitosGeograficos var ambito = await _dbContext.AmbitosGeograficos
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(a => a.MunicipioId == municipioId); // CAMBIO CLAVE: Buscamos por SeccionId y NivelId para ser precisos
.FirstOrDefaultAsync(a => a.SeccionId == seccionId && a.NivelId == 30);
if (ambito == null) if (ambito == null)
{ {
return NotFound(new { message = $"No se encontró el municipio con ID {municipioId}" }); return NotFound(new { message = $"No se encontró el partido con ID {seccionId}" });
} }
// 2. Buscamos el estado del recuento para ese ámbito // 2. Buscamos el estado del recuento para ese ámbito
@@ -41,25 +42,34 @@ public class ResultadosController : ControllerBase
if (estadoRecuento == null) if (estadoRecuento == null)
{ {
return NotFound(new { message = $"No se han encontrado resultados para el municipio {ambito.Nombre}" }); // Devolvemos una respuesta vacía pero válida para el frontend
return Ok(new MunicipioResultadosDto
{
MunicipioNombre = ambito.Nombre,
UltimaActualizacion = DateTime.UtcNow,
PorcentajeEscrutado = 0,
PorcentajeParticipacion = 0,
Resultados = new List<AgrupacionResultadoDto>(),
VotosAdicionales = new VotosAdicionalesDto()
});
} }
// 3. Buscamos todos los votos para ese ámbito, incluyendo el nombre de la agrupación // 3. Buscamos todos los votos para ese ámbito
var resultadosVotos = await _dbContext.ResultadosVotos var resultadosVotos = await _dbContext.ResultadosVotos
.AsNoTracking() .AsNoTracking()
.Include(rv => rv.AgrupacionPolitica) // ¡Crucial para obtener el nombre del partido! .Include(rv => rv.AgrupacionPolitica)
.Where(rv => rv.AmbitoGeograficoId == ambito.Id) .Where(rv => rv.AmbitoGeograficoId == ambito.Id)
.ToListAsync(); .ToListAsync();
// 4. Calculamos el total de votos positivos para el porcentaje // 4. Calculamos el total de votos positivos
long totalVotosPositivos = resultadosVotos.Sum(r => r.CantidadVotos); long totalVotosPositivos = resultadosVotos.Sum(r => r.CantidadVotos);
// 5. Mapeamos todo a nuestro DTO de respuesta // 5. Mapeamos al DTO de respuesta
var respuestaDto = new MunicipioResultadosDto var respuestaDto = new MunicipioResultadosDto
{ {
MunicipioNombre = ambito.Nombre, MunicipioNombre = ambito.Nombre,
UltimaActualizacion = estadoRecuento.FechaTotalizacion, UltimaActualizacion = estadoRecuento.FechaTotalizacion,
PorcentajeEscrutado = estadoRecuento.MesasTotalizadas * 100.0m / (estadoRecuento.MesasEsperadas > 0 ? estadoRecuento.MesasEsperadas : 1), PorcentajeEscrutado = estadoRecuento.MesasTotalizadasPorcentaje,
PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje, PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje,
Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto
{ {
@@ -75,7 +85,6 @@ public class ResultadosController : ControllerBase
} }
}; };
// Devolvemos el resultado
return Ok(respuestaDto); return Ok(respuestaDto);
} }
@@ -157,8 +166,6 @@ public class ResultadosController : ControllerBase
[HttpGet("mapa")] [HttpGet("mapa")]
public async Task<IActionResult> GetResultadosParaMapa() public async Task<IActionResult> GetResultadosParaMapa()
{ {
// Esta consulta es mucho más eficiente y se traduce bien a SQL.
// Paso 1: Para cada ámbito, encontrar la cantidad máxima de votos.
var maxVotosPorAmbito = _dbContext.ResultadosVotos var maxVotosPorAmbito = _dbContext.ResultadosVotos
.GroupBy(rv => rv.AmbitoGeograficoId) .GroupBy(rv => rv.AmbitoGeograficoId)
.Select(g => new .Select(g => new
@@ -167,24 +174,93 @@ public class ResultadosController : ControllerBase
MaxVotos = g.Max(v => v.CantidadVotos) MaxVotos = g.Max(v => v.CantidadVotos)
}); });
// Paso 2: Unir los resultados originales con los máximos para encontrar el registro ganador.
// Esto nos da, para cada ámbito, el registro completo del partido que tuvo más votos.
var resultadosGanadores = await _dbContext.ResultadosVotos var resultadosGanadores = await _dbContext.ResultadosVotos
.Join( .Join(
maxVotosPorAmbito, maxVotosPorAmbito,
voto => new { AmbitoId = voto.AmbitoGeograficoId, Votos = voto.CantidadVotos }, voto => new { AmbitoId = voto.AmbitoGeograficoId, Votos = voto.CantidadVotos },
max => new { AmbitoId = max.AmbitoId, Votos = max.MaxVotos }, max => new { AmbitoId = max.AmbitoId, Votos = max.MaxVotos },
(voto, max) => voto // Nos quedamos con el objeto 'ResultadoVoto' completo (voto, max) => voto
) )
.Include(rv => rv.AmbitoGeografico) // Incluimos el ámbito para obtener el MunicipioId .Include(rv => rv.AmbitoGeografico)
.Where(rv => rv.AmbitoGeografico.MunicipioId != null) .Where(rv => rv.AmbitoGeografico.NivelId == 30) // Aseguramos que solo sean los ámbitos de nivel 30
.Select(rv => new .Select(rv => new
{ {
MunicipioId = rv.AmbitoGeografico.MunicipioId, // CORRECCIÓN CLAVE: Devolvemos los campos que el frontend necesita para funcionar.
// 1. El ID de la BD para hacer clic y pedir detalles.
AmbitoId = rv.AmbitoGeografico.Id,
// 2. El NOMBRE del departamento/municipio para encontrar y colorear el polígono.
DepartamentoNombre = rv.AmbitoGeografico.Nombre,
// 3. El ID del partido ganador.
AgrupacionGanadoraId = rv.AgrupacionPoliticaId AgrupacionGanadoraId = rv.AgrupacionPoliticaId
}) })
.ToListAsync(); .ToListAsync();
return Ok(resultadosGanadores); return Ok(resultadosGanadores);
} }
[HttpGet("municipio/{ambitoId}")] // Cambiamos el nombre del parámetro de ruta
public async Task<IActionResult> GetResultadosPorMunicipio(int ambitoId) // Cambiamos el tipo de string a int
{
_logger.LogInformation("Buscando resultados para AmbitoGeograficoId: {AmbitoId}", ambitoId);
// PASO 1: Buscar el Ámbito Geográfico directamente por su CLAVE PRIMARIA (AmbitoGeograficoId).
var ambito = await _dbContext.AmbitosGeograficos
.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == ambitoId && a.NivelId == 30); // Usamos a.Id == ambitoId
if (ambito == null)
{
_logger.LogWarning("No se encontró el ámbito para el ID interno: {AmbitoId} o no es Nivel 30.", ambitoId);
return NotFound(new { message = $"No se encontró el municipio con ID interno {ambitoId}" });
}
_logger.LogInformation("Ámbito encontrado: Id={AmbitoId}, Nombre={AmbitoNombre}", ambito.Id, ambito.Nombre);
// PASO 2: Usar la CLAVE PRIMARIA (ambito.Id) para buscar el estado del recuento.
var estadoRecuento = await _dbContext.EstadosRecuentos
.AsNoTracking()
.FirstOrDefaultAsync(e => e.AmbitoGeograficoId == ambito.Id);
if (estadoRecuento == null)
{
_logger.LogWarning("No se encontró EstadoRecuento para AmbitoGeograficoId: {AmbitoId}", ambito.Id);
return NotFound(new { message = $"No se han encontrado resultados de recuento para el municipio {ambito.Nombre}" });
}
// PASO 3: Usar la CLAVE PRIMARIA (ambito.Id) para buscar los votos.
var resultadosVotos = await _dbContext.ResultadosVotos
.AsNoTracking()
.Include(rv => rv.AgrupacionPolitica) // Incluimos el nombre del partido
.Where(rv => rv.AmbitoGeograficoId == ambito.Id)
.OrderByDescending(rv => rv.CantidadVotos)
.ToListAsync();
// PASO 4: Calcular el total de votos positivos para el porcentaje.
long totalVotosPositivos = resultadosVotos.Sum(r => r.CantidadVotos);
// PASO 5: Mapear todo al DTO de respuesta que el frontend espera.
var respuestaDto = new MunicipioResultadosDto
{
MunicipioNombre = ambito.Nombre,
UltimaActualizacion = estadoRecuento.FechaTotalizacion,
PorcentajeEscrutado = estadoRecuento.MesasTotalizadasPorcentaje,
PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje,
Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto
{
Nombre = rv.AgrupacionPolitica.Nombre,
Votos = rv.CantidadVotos,
Porcentaje = totalVotosPositivos > 0 ? (rv.CantidadVotos * 100.0m / totalVotosPositivos) : 0
}).ToList(),
VotosAdicionales = new VotosAdicionalesDto
{
EnBlanco = estadoRecuento.VotosEnBlanco,
Nulos = estadoRecuento.VotosNulos,
Recurridos = estadoRecuento.VotosRecurridos
}
};
return Ok(respuestaDto);
}
} }

View File

@@ -7,5 +7,6 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"AllowedOrigins": "http://localhost:5173"
} }

View File

@@ -1253,6 +1253,14 @@
} }
}, },
"System.Threading.Channels/7.0.0": {}, "System.Threading.Channels/7.0.0": {},
"System.Threading.RateLimiting/9.0.8": {
"runtime": {
"lib/net9.0/System.Threading.RateLimiting.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.825.36511"
}
}
},
"System.Threading.Tasks.Extensions/4.5.4": {}, "System.Threading.Tasks.Extensions/4.5.4": {},
"System.Windows.Extensions/6.0.0": { "System.Windows.Extensions/6.0.0": {
"dependencies": { "dependencies": {
@@ -1296,7 +1304,8 @@
"dependencies": { "dependencies": {
"Elecciones.Core": "1.0.0", "Elecciones.Core": "1.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8", "Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.Http": "9.0.8" "Microsoft.Extensions.Http": "9.0.8",
"System.Threading.RateLimiting": "9.0.8"
}, },
"runtime": { "runtime": {
"Elecciones.Infrastructure.dll": { "Elecciones.Infrastructure.dll": {
@@ -2020,6 +2029,13 @@
"path": "system.threading.channels/7.0.0", "path": "system.threading.channels/7.0.0",
"hashPath": "system.threading.channels.7.0.0.nupkg.sha512" "hashPath": "system.threading.channels.7.0.0.nupkg.sha512"
}, },
"System.Threading.RateLimiting/9.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Kr2vtbLUyZSGz40YoqE1FrNlXyGj4qOvNmm9upEVxLgT8pr/yEubhDMU5xs70ruhchuWO0LrFi76YWHjYUP/SA==",
"path": "system.threading.ratelimiting/9.0.8",
"hashPath": "system.threading.ratelimiting.9.0.8.nupkg.sha512"
},
"System.Threading.Tasks.Extensions/4.5.4": { "System.Threading.Tasks.Extensions/4.5.4": {
"type": "package", "type": "package",
"serviceable": true, "serviceable": true,

View File

@@ -7,5 +7,6 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"AllowedOrigins": "http://localhost:5173"
} }

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+68dce9415e165633856e4fae9b2d71cc07b4e2ff")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+18e6e8d3c0a378a172ad8e8afd31109673460717")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -183,3 +183,4 @@ E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Se
E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.Console.dll E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.Console.dll
E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.Debug.dll E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.Debug.dll
E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.File.dll E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\Serilog.Sinks.File.dll
E:\Elecciones-2025\Elecciones-Web\src\Elecciones.Api\bin\Debug\net9.0\System.Threading.RateLimiting.dll

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["mhE0FuBM0BOF9SNOE0rY9setCw2ye3UUh7cEPjhgDdY=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","ywKm3DCyXg4YCbZAIx3JUlT8N4Irff3GswYUVDST\u002BjQ=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","7rMeSoKKF2\u002B9j5kLZ30FlE98meJ1tr4dywVzhYb49Qg="],"CachedAssets":{},"CachedCopyCandidates":{}} {"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["Dji\u002Bta/0e7zUKw3oe\u002BriV3kbWxZ93FP2z2QIYsHXTl4=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","ywKm3DCyXg4YCbZAIx3JUlT8N4Irff3GswYUVDST\u002BjQ=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","42z\u002Bw0pajpMLFLNS29VoU/hUn9IzvZ/pVLNadS0rApY=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","ifGdmI/zx2hsc8PYkk8IWTP8aZ9RYmaQbfk383bAiYQ="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["mhE0FuBM0BOF9SNOE0rY9setCw2ye3UUh7cEPjhgDdY=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","ywKm3DCyXg4YCbZAIx3JUlT8N4Irff3GswYUVDST\u002BjQ=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","7rMeSoKKF2\u002B9j5kLZ30FlE98meJ1tr4dywVzhYb49Qg="],"CachedAssets":{},"CachedCopyCandidates":{}} {"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["Dji\u002Bta/0e7zUKw3oe\u002BriV3kbWxZ93FP2z2QIYsHXTl4=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","ywKm3DCyXg4YCbZAIx3JUlT8N4Irff3GswYUVDST\u002BjQ=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","42z\u002Bw0pajpMLFLNS29VoU/hUn9IzvZ/pVLNadS0rApY=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","ifGdmI/zx2hsc8PYkk8IWTP8aZ9RYmaQbfk383bAiYQ="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"O7YawHw32G/Fh2bs+snZgm9O7okI0WYgTQmXM931znY=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["mhE0FuBM0BOF9SNOE0rY9setCw2ye3UUh7cEPjhgDdY=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM="],"CachedAssets":{},"CachedCopyCandidates":{}} {"GlobalPropertiesHash":"O7YawHw32G/Fh2bs+snZgm9O7okI0WYgTQmXM931znY=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["Dji\u002Bta/0e7zUKw3oe\u002BriV3kbWxZ93FP2z2QIYsHXTl4=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+30f1e751b770bf730fc48b1baefb00f560694f35")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+18e6e8d3c0a378a172ad8e8afd31109673460717")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+30f1e751b770bf730fc48b1baefb00f560694f35")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+18e6e8d3c0a378a172ad8e8afd31109673460717")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"Elecciones.Core": "1.0.0", "Elecciones.Core": "1.0.0",
"Microsoft.Extensions.Configuration.Abstractions": "9.0.8", "Microsoft.Extensions.Configuration.Abstractions": "9.0.8",
"Microsoft.Extensions.Http": "9.0.8" "Microsoft.Extensions.Http": "9.0.8",
"System.Threading.RateLimiting": "9.0.8"
}, },
"runtime": { "runtime": {
"Elecciones.Infrastructure.dll": {} "Elecciones.Infrastructure.dll": {}
@@ -169,6 +170,14 @@
} }
} }
}, },
"System.Threading.RateLimiting/9.0.8": {
"runtime": {
"lib/net9.0/System.Threading.RateLimiting.dll": {
"assemblyVersion": "9.0.0.0",
"fileVersion": "9.0.825.36511"
}
}
},
"Elecciones.Core/1.0.0": { "Elecciones.Core/1.0.0": {
"runtime": { "runtime": {
"Elecciones.Core.dll": { "Elecciones.Core.dll": {
@@ -276,6 +285,13 @@
"path": "microsoft.extensions.primitives/9.0.8", "path": "microsoft.extensions.primitives/9.0.8",
"hashPath": "microsoft.extensions.primitives.9.0.8.nupkg.sha512" "hashPath": "microsoft.extensions.primitives.9.0.8.nupkg.sha512"
}, },
"System.Threading.RateLimiting/9.0.8": {
"type": "package",
"serviceable": true,
"sha512": "sha512-Kr2vtbLUyZSGz40YoqE1FrNlXyGj4qOvNmm9upEVxLgT8pr/yEubhDMU5xs70ruhchuWO0LrFi76YWHjYUP/SA==",
"path": "system.threading.ratelimiting/9.0.8",
"hashPath": "system.threading.ratelimiting.9.0.8.nupkg.sha512"
},
"Elecciones.Core/1.0.0": { "Elecciones.Core/1.0.0": {
"type": "project", "type": "project",
"serviceable": false, "serviceable": false,

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+18e6e8d3c0a378a172ad8e8afd31109673460717")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+19b37f73206d043982fc77f8c2359f2598889b64")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+18e6e8d3c0a378a172ad8e8afd31109673460717")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Worker")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]