Feat Front Widgets Refactizados y Ajustes Backend
This commit is contained in:
		
							
								
								
									
										42
									
								
								Elecciones-Web/Restaurar/Nuevos/App.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								Elecciones-Web/Restaurar/Nuevos/App.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| #root { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 2rem; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .logo { | ||||
|   height: 6em; | ||||
|   padding: 1.5em; | ||||
|   will-change: filter; | ||||
|   transition: filter 300ms; | ||||
| } | ||||
| .logo:hover { | ||||
|   filter: drop-shadow(0 0 2em #646cffaa); | ||||
| } | ||||
| .logo.react:hover { | ||||
|   filter: drop-shadow(0 0 2em #61dafbaa); | ||||
| } | ||||
|  | ||||
| @keyframes logo-spin { | ||||
|   from { | ||||
|     transform: rotate(0deg); | ||||
|   } | ||||
|   to { | ||||
|     transform: rotate(360deg); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (prefers-reduced-motion: no-preference) { | ||||
|   a:nth-of-type(2) .logo { | ||||
|     animation: logo-spin infinite 20s linear; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .card { | ||||
|   padding: 2em; | ||||
| } | ||||
|  | ||||
| .read-the-docs { | ||||
|   color: #888; | ||||
| } | ||||
							
								
								
									
										18
									
								
								Elecciones-Web/Restaurar/Nuevos/App.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								Elecciones-Web/Restaurar/Nuevos/App.tsx
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										58
									
								
								Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										217
									
								
								Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								Elecciones-Web/Restaurar/Nuevos/components/MapaBsAs.tsx
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										68
									
								
								Elecciones-Web/Restaurar/Nuevos/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								Elecciones-Web/Restaurar/Nuevos/index.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | ||||
| :root { | ||||
|   font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; | ||||
|   line-height: 1.5; | ||||
|   font-weight: 400; | ||||
|  | ||||
|   color-scheme: light dark; | ||||
|   color: rgba(255, 255, 255, 0.87); | ||||
|   background-color: #242424; | ||||
|  | ||||
|   font-synthesis: none; | ||||
|   text-rendering: optimizeLegibility; | ||||
|   -webkit-font-smoothing: antialiased; | ||||
|   -moz-osx-font-smoothing: grayscale; | ||||
| } | ||||
|  | ||||
| a { | ||||
|   font-weight: 500; | ||||
|   color: #646cff; | ||||
|   text-decoration: inherit; | ||||
| } | ||||
| a:hover { | ||||
|   color: #535bf2; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   margin: 0; | ||||
|   display: flex; | ||||
|   place-items: center; | ||||
|   min-width: 320px; | ||||
|   min-height: 100vh; | ||||
| } | ||||
|  | ||||
| h1 { | ||||
|   font-size: 3.2em; | ||||
|   line-height: 1.1; | ||||
| } | ||||
|  | ||||
| button { | ||||
|   border-radius: 8px; | ||||
|   border: 1px solid transparent; | ||||
|   padding: 0.6em 1.2em; | ||||
|   font-size: 1em; | ||||
|   font-weight: 500; | ||||
|   font-family: inherit; | ||||
|   background-color: #1a1a1a; | ||||
|   cursor: pointer; | ||||
|   transition: border-color 0.25s; | ||||
| } | ||||
| button:hover { | ||||
|   border-color: #646cff; | ||||
| } | ||||
| button:focus, | ||||
| button:focus-visible { | ||||
|   outline: 4px auto -webkit-focus-ring-color; | ||||
| } | ||||
|  | ||||
| @media (prefers-color-scheme: light) { | ||||
|   :root { | ||||
|     color: #213547; | ||||
|     background-color: #ffffff; | ||||
|   } | ||||
|   a:hover { | ||||
|     color: #747bff; | ||||
|   } | ||||
|   button { | ||||
|     background-color: #f9f9f9; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										17
									
								
								Elecciones-Web/Restaurar/Nuevos/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Elecciones-Web/Restaurar/Nuevos/main.tsx
									
									
									
									
									
										Normal 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
											
										
									
								
							
							
								
								
									
										12
									
								
								Elecciones-Web/Restaurar/Nuevos/types/custom.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								Elecciones-Web/Restaurar/Nuevos/types/custom.d.ts
									
									
									
									
										vendored
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										56
									
								
								Elecciones-Web/Restaurar/apiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								Elecciones-Web/Restaurar/apiService.ts
									
									
									
									
									
										Normal 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; | ||||
| }; | ||||
							
								
								
									
										35
									
								
								Elecciones-Web/Restaurar/components/BancasWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Elecciones-Web/Restaurar/components/BancasWidget.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										91
									
								
								Elecciones-Web/Restaurar/components/BancasWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								Elecciones-Web/Restaurar/components/BancasWidget.tsx
									
									
									
									
									
										Normal 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> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								Elecciones-Web/Restaurar/components/TickerWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Elecciones-Web/Restaurar/components/TickerWidget.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										67
									
								
								Elecciones-Web/Restaurar/components/TickerWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								Elecciones-Web/Restaurar/components/TickerWidget.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										1352
									
								
								Elecciones-Web/frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1352
									
								
								Elecciones-Web/frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -10,14 +10,18 @@ | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@nivo/waffle": "^0.99.0", | ||||
|     "@tanstack/react-query": "^5.85.5", | ||||
|     "@types/d3-geo": "^3.1.0", | ||||
|     "axios": "^1.11.0", | ||||
|     "d3": "^7.9.0", | ||||
|     "d3-geo": "^3.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": { | ||||
|     "@eslint/js": "^9.33.0", | ||||
|     "@types/d3": "^7.4.3", | ||||
|     "@types/geojson": "^7946.0.16", | ||||
|     "@types/react": "^19.1.10", | ||||
|     "@types/react-dom": "^19.1.7", | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -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" | ||||
| } | ||||
							
								
								
									
										1
									
								
								Elecciones-Web/frontend/public/partidos-bsas.topojson
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								Elecciones-Web/frontend/public/partidos-bsas.topojson
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -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 | 
| @@ -40,7 +40,3 @@ | ||||
| .read-the-docs { | ||||
|   color: #888; | ||||
| } | ||||
|  | ||||
| section { | ||||
|   margin-bottom: 2rem; | ||||
| } | ||||
| @@ -1,69 +1,20 @@ | ||||
| // src/App.tsx | ||||
| import { useState } from 'react'; | ||||
| import { MunicipioWidget } from './components/MunicipioWidget'; | ||||
| import { MunicipioSelector } from './components/MunicipioSelector'; | ||||
| import './App.css'; | ||||
| import { ResumenProvincialWidget } from './components/ResumenProvincialWidget'; | ||||
| import { BancasWidget } from './components/BancasWidget'; | ||||
| import { TelegramasView } from './components/TelegramasView'; | ||||
| import './App.css' | ||||
| //import { BancasWidget } from './components/BancasWidget' | ||||
| import MapaBsAs from './components/MapaBsAs' | ||||
| import { TickerWidget } from './components/TickerWidget' | ||||
|  | ||||
| function App() { | ||||
|   const [selectedMunicipioId, setSelectedMunicipioId] = useState<string | null>(null); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <h1>Elecciones 2025 - Resultados en Vivo</h1> | ||||
|       <section> | ||||
|         <ResumenProvincialWidget distritoId="02" /> | ||||
|       </section> | ||||
|  | ||||
|       <hr /> | ||||
|        | ||||
|        | ||||
|        | ||||
|       <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> | ||||
|       <h1>Resultados Electorales - Provincia de Buenos Aires</h1> | ||||
|       <main> | ||||
|         <TickerWidget /> | ||||
|         {/*<BancasWidget />*/} | ||||
|         <MapaBsAs /> | ||||
|       </main> | ||||
|     </> | ||||
|   ); | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export default App; | ||||
| export default App | ||||
							
								
								
									
										56
									
								
								Elecciones-Web/frontend/src/apiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								Elecciones-Web/frontend/src/apiService.ts
									
									
									
									
									
										Normal 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; | ||||
| }; | ||||
							
								
								
									
										35
									
								
								Elecciones-Web/frontend/src/components/BancasWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Elecciones-Web/frontend/src/components/BancasWidget.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
| @@ -1,52 +1,91 @@ | ||||
| // src/components/BancasWidget.tsx | ||||
| 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 { | ||||
|   seccionId: string; | ||||
| } | ||||
| // Paleta de colores consistente | ||||
| const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]; | ||||
|  | ||||
| export const BancasWidget = ({ seccionId }: Props) => { | ||||
|   const [data, setData] = useState<ProyeccionBancas | null>(null); | ||||
|   const [loading, setLoading] = useState(true); | ||||
| // 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)' }, | ||||
| ]; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchData = async () => { | ||||
|       try { | ||||
|         setLoading(true); | ||||
|         const proyeccion = await getBancasPorSeccion(seccionId); | ||||
|         setData(proyeccion); | ||||
|       } catch (err) { | ||||
|         console.error("Error cargando bancas", err); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|     fetchData(); | ||||
|   }, [seccionId]); | ||||
| 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); | ||||
|  | ||||
|   if (loading) return <div>Cargando proyección de bancas...</div>; | ||||
|   if (!data) return <div>No hay datos de bancas disponibles.</div>; | ||||
|     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 | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ fontFamily: 'sans-serif', border: '1px solid #ccc', padding: '16px', borderRadius: '8px' }}> | ||||
|       <h3>Proyección de Bancas - {data.seccionNombre}</h3> | ||||
|       <table style={{ width: '100%', borderCollapse: 'collapse' }}> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th style={{ textAlign: 'left' }}>Agrupación</th> | ||||
|             <th style={{ textAlign: 'right' }}>Bancas Obtenidas</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           {data.proyeccion.map((partido) => ( | ||||
|             <tr key={partido.agrupacionNombre}> | ||||
|               <td>{partido.agrupacionNombre}</td> | ||||
|               <td style={{ textAlign: 'right' }}>{partido.bancas}</td> | ||||
|             </tr> | ||||
|           ))} | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|   ); | ||||
|     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> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										125
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.css
									
									
									
									
									
										Normal 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; } | ||||
							
								
								
									
										264
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								Elecciones-Web/frontend/src/components/MapaBsAs.tsx
									
									
									
									
									
										Normal 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; | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
| @@ -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> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										67
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								Elecciones-Web/frontend/src/components/TickerWidget.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,11 +1,13 @@ | ||||
| /* src/index.css */ | ||||
| :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; | ||||
|   /* Tema Claro por defecto */ | ||||
|   color-scheme: light; | ||||
|   color: #213547; | ||||
|   background-color: #ffffff; | ||||
|  | ||||
|   font-synthesis: none; | ||||
|   text-rendering: optimizeLegibility; | ||||
| @@ -42,7 +44,8 @@ button { | ||||
|   font-size: 1em; | ||||
|   font-weight: 500; | ||||
|   font-family: inherit; | ||||
|   background-color: #1a1a1a; | ||||
|   /* Color de fondo para botones en tema claro */ | ||||
|   background-color: #f9f9f9; | ||||
|   cursor: pointer; | ||||
|   transition: border-color 0.25s; | ||||
| } | ||||
| @@ -52,17 +55,4 @@ button:hover { | ||||
| 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; | ||||
|   } | ||||
| } | ||||
| } | ||||
| @@ -1,10 +1,17 @@ | ||||
| import { StrictMode } from 'react' | ||||
| import { createRoot } from 'react-dom/client' | ||||
| import './index.css' | ||||
| // src/main.tsx | ||||
| import React from 'react' | ||||
| import ReactDOM from 'react-dom/client' | ||||
| import { QueryClient, QueryClientProvider } from '@tanstack/react-query' | ||||
| import App from './App.tsx' | ||||
| import './index.css' | ||||
|  | ||||
| createRoot(document.getElementById('root')!).render( | ||||
|   <StrictMode> | ||||
|     <App /> | ||||
|   </StrictMode>, | ||||
| ) | ||||
| // Crear un cliente de React Query | ||||
| const queryClient = new QueryClient() | ||||
|  | ||||
| ReactDOM.createRoot(document.getElementById('root')!).render( | ||||
|   <React.StrictMode> | ||||
|     <QueryClientProvider client={queryClient}> | ||||
|       <App /> | ||||
|     </QueryClientProvider> | ||||
|   </React.StrictMode>, | ||||
| ) | ||||
| @@ -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; | ||||
| }; | ||||
							
								
								
									
										11
									
								
								Elecciones-Web/frontend/src/types/custom.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								Elecciones-Web/frontend/src/types/custom.d.ts
									
									
									
									
										vendored
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										62
									
								
								Elecciones-Web/frontend/src/types/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								Elecciones-Web/frontend/src/types/types.ts
									
									
									
									
									
										Normal 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[]; } | ||||
| @@ -21,13 +21,13 @@ public class CatalogosController : ControllerBase | ||||
|     [HttpGet("municipios")] | ||||
|     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 | ||||
|             .AsNoTracking() | ||||
|             .Where(a => a.NivelId == 5 && a.MunicipioId != null) | ||||
|             .Where(a => a.NivelId == 30 && a.SeccionId != null) // <-- NivelId 30 | ||||
|             .Select(a => new MunicipioSimpleDto | ||||
|             { | ||||
|                 Id = a.MunicipioId!, | ||||
|                 Id = a.SeccionId!, // <-- Usamos SeccionId como el ID | ||||
|                 Nombre = a.Nombre | ||||
|             }) | ||||
|             .OrderBy(m => m.Nombre) | ||||
| @@ -35,4 +35,14 @@ public class CatalogosController : ControllerBase | ||||
|  | ||||
|         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); | ||||
|     } | ||||
| } | ||||
| @@ -21,17 +21,18 @@ public class ResultadosController : ControllerBase | ||||
|         _logger = logger; | ||||
|     } | ||||
|  | ||||
|     [HttpGet("municipio/{municipioId}")] | ||||
|     public async Task<IActionResult> GetResultadosPorMunicipio(string municipioId) | ||||
|     [HttpGet("partido/{seccionId}")] | ||||
|     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 | ||||
|             .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) | ||||
|         { | ||||
|             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 | ||||
| @@ -41,25 +42,34 @@ public class ResultadosController : ControllerBase | ||||
|  | ||||
|         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 | ||||
|             .AsNoTracking() | ||||
|             .Include(rv => rv.AgrupacionPolitica) // ¡Crucial para obtener el nombre del partido! | ||||
|             .Include(rv => rv.AgrupacionPolitica) | ||||
|             .Where(rv => rv.AmbitoGeograficoId == ambito.Id) | ||||
|             .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); | ||||
|  | ||||
|         // 5. Mapeamos todo a nuestro DTO de respuesta | ||||
|         // 5. Mapeamos al DTO de respuesta | ||||
|         var respuestaDto = new MunicipioResultadosDto | ||||
|         { | ||||
|             MunicipioNombre = ambito.Nombre, | ||||
|             UltimaActualizacion = estadoRecuento.FechaTotalizacion, | ||||
|             PorcentajeEscrutado = estadoRecuento.MesasTotalizadas * 100.0m / (estadoRecuento.MesasEsperadas > 0 ? estadoRecuento.MesasEsperadas : 1), | ||||
|             PorcentajeEscrutado = estadoRecuento.MesasTotalizadasPorcentaje, | ||||
|             PorcentajeParticipacion = estadoRecuento.ParticipacionPorcentaje, | ||||
|             Resultados = resultadosVotos.Select(rv => new AgrupacionResultadoDto | ||||
|             { | ||||
| @@ -75,7 +85,6 @@ public class ResultadosController : ControllerBase | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         // Devolvemos el resultado | ||||
|         return Ok(respuestaDto); | ||||
|     } | ||||
|  | ||||
| @@ -157,8 +166,6 @@ public class ResultadosController : ControllerBase | ||||
|     [HttpGet("mapa")] | ||||
|     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 | ||||
|             .GroupBy(rv => rv.AmbitoGeograficoId) | ||||
|             .Select(g => new | ||||
| @@ -167,24 +174,93 @@ public class ResultadosController : ControllerBase | ||||
|                 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 | ||||
|             .Join( | ||||
|                 maxVotosPorAmbito, | ||||
|                 voto => new { AmbitoId = voto.AmbitoGeograficoId, Votos = voto.CantidadVotos }, | ||||
|                 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 | ||||
|             .Where(rv => rv.AmbitoGeografico.MunicipioId != null) | ||||
|             .Include(rv => rv.AmbitoGeografico) | ||||
|             .Where(rv => rv.AmbitoGeografico.NivelId == 30) // Aseguramos que solo sean los ámbitos de nivel 30 | ||||
|             .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 | ||||
|             }) | ||||
|             .ToListAsync(); | ||||
|  | ||||
|         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); | ||||
|     } | ||||
| } | ||||
| @@ -7,5 +7,6 @@ | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   } | ||||
|   }, | ||||
|   "AllowedOrigins": "http://localhost:5173" | ||||
| } | ||||
|   | ||||
| @@ -1253,6 +1253,14 @@ | ||||
|         } | ||||
|       }, | ||||
|       "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.Windows.Extensions/6.0.0": { | ||||
|         "dependencies": { | ||||
| @@ -1296,7 +1304,8 @@ | ||||
|         "dependencies": { | ||||
|           "Elecciones.Core": "1.0.0", | ||||
|           "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": { | ||||
|           "Elecciones.Infrastructure.dll": { | ||||
| @@ -2020,6 +2029,13 @@ | ||||
|       "path": "system.threading.channels/7.0.0", | ||||
|       "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": { | ||||
|       "type": "package", | ||||
|       "serviceable": true, | ||||
|   | ||||
| @@ -7,5 +7,6 @@ | ||||
|       "Default": "Information", | ||||
|       "Microsoft.AspNetCore": "Warning" | ||||
|     } | ||||
|   } | ||||
|   }, | ||||
|   "AllowedOrigins": "http://localhost:5173" | ||||
| } | ||||
|   | ||||
| @@ -14,7 +14,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Api")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -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.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\System.Threading.RateLimiting.dll | ||||
|   | ||||
| @@ -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":{}} | ||||
| @@ -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":{}} | ||||
| @@ -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":{}} | ||||
| @@ -13,7 +13,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Core")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -13,7 +13,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Database")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Database")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -10,7 +10,8 @@ | ||||
|         "dependencies": { | ||||
|           "Elecciones.Core": "1.0.0", | ||||
|           "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": { | ||||
|           "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": { | ||||
|         "runtime": { | ||||
|           "Elecciones.Core.dll": { | ||||
| @@ -276,6 +285,13 @@ | ||||
|       "path": "microsoft.extensions.primitives/9.0.8", | ||||
|       "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": { | ||||
|       "type": "project", | ||||
|       "serviceable": false, | ||||
|   | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -13,7 +13,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Infrastructure")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
| @@ -14,7 +14,7 @@ using System.Reflection; | ||||
| [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Worker")] | ||||
| [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] | ||||
| [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.AssemblyTitleAttribute("Elecciones.Worker")] | ||||
| [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] | ||||
|   | ||||
		Reference in New Issue
	
	Block a user