Compare commits
	
		
			142 Commits
		
	
	
		
			f961f9d8e7
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1a62a6655a | |||
| 1357f25953 | |||
| 63cc5ecec8 | |||
| 3a43c4a74a | |||
| ef1c1e41dc | |||
| c36f4b6153 | |||
| 99406d10ee | |||
| 8d7f5c1db6 | |||
| 21002445b2 | |||
| 70069d46f7 | |||
| ad883257a3 | |||
| 1335b54d75 | |||
| 983ed5e39c | |||
| e98e152f0e | |||
| 248171146d | |||
| 4dbda0da63 | |||
| 3c364ef373 | |||
| 814b24cefb | |||
| f89903feda | |||
| 0ee092d6ed | |||
| db469ffba6 | |||
| 5ef3eb1af2 | |||
| bea752f7d0 | |||
| a0e587d8b5 | |||
| ced1ae6b3f | |||
| c5c1872ab8 | |||
| c50e4210b5 | |||
| 4cefb833d9 | |||
| a78fcf66c0 | |||
| 99d56033b1 | |||
| 5c11763386 | |||
| 9cd91581bf | |||
| d6b4c3cc4d | |||
| 069446b903 | |||
| 2b7fb927e2 | |||
| 705683861c | |||
| 17a5b333fd | |||
| ae846f2d48 | |||
| 4bc257df43 | |||
| 6892252a9b | |||
| 92c80f195b | |||
| 45421f5c5f | |||
| 903c2b6a94 | |||
| 7317c06650 | |||
| fca65edefc | |||
| 6cd09343f2 | |||
| 09c4d61b71 | |||
| 705a6f0f5e | |||
| 316f49f25b | |||
| 84f7643907 | |||
| 2736301338 | |||
| a316e5dd08 | |||
| ce4fc52d4a | |||
| fa261ba828 | |||
| 3c8c4917fd | |||
| 68f31f2873 | |||
| 9e0e7f0ee6 | |||
| b8c8c1260d | |||
| 64d45a7a39 | |||
| 1719e79723 | |||
| e0755a5347 | |||
| e9b0eeb630 | |||
| 63cc042eb4 | |||
| 11d9417ef5 | |||
| ed5b78e6c8 | |||
| a985cbfd7c | |||
| 3b0eee25e6 | |||
| 67634ae947 | |||
| 5a8bee52d5 | |||
| 3750d1a56d | |||
| 7d2922aaeb | |||
| 3a8f64bf85 | |||
| 64dc7ef440 | |||
| 843c0f7258 | |||
| 326b6b3c59 | |||
| 143759a929 | |||
| 153c0f92da | |||
| 6309003536 | |||
| f4d4cd173f | |||
| 2fac830528 | |||
| d091d91f89 | |||
| 9e0cf30294 | |||
| d5168e1d17 | |||
| 2169b57bc6 | |||
| 865cc488df | |||
| 70c8ce54de | |||
| c105106f3b | |||
| f497c89ffa | |||
| 565128321c | |||
| 2b47d8e20d | |||
| fc97e29f13 | |||
| 7f49362e55 | |||
| 50d3c6bce9 | |||
| ad30d4263d | |||
| a49fc80fd9 | |||
| 475b886d9a | |||
| fa92d9638c | |||
| f384a640f3 | |||
| 31d434b2aa | |||
| 1d9ed05446 | |||
| d52c452009 | |||
| 6354791f28 | |||
| a495ab67ef | |||
| c48cc1bec5 | |||
| 12acd61f2b | |||
| d78a02a0eb | |||
| 479c2c60f2 | |||
| 0ce5e2e2c9 | |||
| 2db20969a1 | |||
| f41b4eaa1c | |||
| ff6c2f29e7 | |||
| 5d0f2460f9 | |||
| 8ce48b3a46 | |||
| 29f8146b32 | |||
| 83047721a3 | |||
| c23e6f136e | |||
| fdeacda683 | |||
| ef3967fcd6 | |||
| cb329da04e | |||
| a5638e3e91 | |||
| 46b2d2cfe6 | |||
| a7e231dfb6 | |||
| 25def576e9 | |||
| 025f051839 | |||
| 82371bd159 | |||
| eee21b8d52 | |||
| 675c1f83a5 | |||
| 6ebe62ef87 | |||
| cf571cbc14 | |||
| 7bc96fec2b | |||
| 0c1cca64a8 | |||
| 3381dd1778 | |||
| 57fe26afa9 | |||
| 6a508f468b | |||
| 18439c40d7 | |||
| b8e6d33afa | |||
| 36a004a0b0 | |||
| a81f1fe894 | |||
| 32e85b9b9d | |||
| 6732a0e826 | |||
| 9393d2bc05 | |||
| 6ac6034255 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -28,6 +28,7 @@ build/ | ||||
| *.userprefs | ||||
| /bin/ | ||||
| /obj/ | ||||
| /debug/ | ||||
| project.lock.json | ||||
| project.assets.json | ||||
| /packages/ | ||||
							
								
								
									
										18
									
								
								Elecciones-Web/frontend-admin/Dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| # frontend-admin/Dockerfile | ||||
|  | ||||
| # --- Etapa 1: Build (Construcción) --- | ||||
| FROM node:20-alpine AS build | ||||
| WORKDIR /app | ||||
| COPY package*.json ./ | ||||
| RUN npm install | ||||
| COPY . . | ||||
| RUN npm run build | ||||
|  | ||||
| # --- Etapa 2: Serve (Servir con Nginx configurado para SPA) --- | ||||
| FROM nginx:1.25-alpine | ||||
| COPY --from=build /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # Copiamos nuestra configuración de Nginx para manejar el enrutamiento de React | ||||
| COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| EXPOSE 80 | ||||
							
								
								
									
										18
									
								
								Elecciones-Web/frontend-admin/frontend.nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,18 @@ | ||||
| # frontend-admin/frontend.nginx.conf | ||||
|  | ||||
| server { | ||||
|     listen 80; | ||||
|     server_name localhost; | ||||
|  | ||||
|     root /usr/share/nginx/html; | ||||
|     index index.html; | ||||
|  | ||||
|     location / { | ||||
|         try_files $uri $uri/ /index.html; | ||||
|     } | ||||
|  | ||||
|     location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ { | ||||
|         expires 1y; | ||||
|         add_header Cache-Control "public"; | ||||
|     } | ||||
| } | ||||
| @@ -2,9 +2,9 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/eldia.svg" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Vite + React + TS</title> | ||||
|     <title>Elecciones 2025 - El Día</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|   | ||||
							
								
								
									
										6
									
								
								Elecciones-Web/frontend-admin/public/eldia.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69"> | ||||
| <path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/> | ||||
| <path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/> | ||||
| <path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
| @@ -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 | 
| @@ -0,0 +1,79 @@ | ||||
| // src/components/AddAgrupacionForm.tsx | ||||
| import { useState } from 'react'; | ||||
| import { createAgrupacion } from '../services/apiService'; | ||||
| import type { CreateAgrupacionData } from '../services/apiService'; | ||||
| // Importa el nuevo archivo CSS si lo creaste, o el existente | ||||
| import './FormStyles.css'; | ||||
|  | ||||
| interface Props { | ||||
|   onSuccess: () => void; | ||||
| } | ||||
|  | ||||
| export const AddAgrupacionForm = ({ onSuccess }: Props) => { | ||||
|   const [nombre, setNombre] = useState(''); | ||||
|   const [nombreCorto, setNombreCorto] = useState(''); | ||||
|   const [color, setColor] = useState('#000000'); | ||||
|   const [error, setError] = useState(''); | ||||
|   const [isLoading, setIsLoading] = useState(false); | ||||
|  | ||||
|   const handleSubmit = async (e: React.FormEvent) => { | ||||
|     e.preventDefault(); | ||||
|     if (!nombre.trim()) { | ||||
|       setError('El nombre es obligatorio.'); | ||||
|       return; | ||||
|     } | ||||
|     setIsLoading(true); | ||||
|     setError(''); | ||||
|  | ||||
|     const payload: CreateAgrupacionData = { | ||||
|       nombre: nombre.trim(), | ||||
|       nombreCorto: nombreCorto.trim() || null, | ||||
|       color: color, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       await createAgrupacion(payload); | ||||
|       alert(`Partido '${payload.nombre}' creado con éxito.`); | ||||
|       // Limpiar formulario | ||||
|       setNombre(''); | ||||
|       setNombreCorto(''); | ||||
|       setColor('#000000'); | ||||
|       // Notificar al componente padre para que refresque los datos | ||||
|       onSuccess(); | ||||
|     } catch (err: any) { | ||||
|       const errorMessage = err.response?.data?.message || 'Ocurrió un error inesperado.'; | ||||
|       setError(errorMessage); | ||||
|       console.error(err); | ||||
|     } finally { | ||||
|       setIsLoading(false); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div className="add-entity-form-container"> | ||||
|       <h4>Añadir Partido Manualmente</h4> | ||||
|       <form onSubmit={handleSubmit} className="add-entity-form"> | ||||
|  | ||||
|         <div className="form-field"> | ||||
|           <label>Nombre Completo</label> | ||||
|           <input type="text" value={nombre} onChange={e => setNombre(e.target.value)} required /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="form-field"> | ||||
|           <label>Nombre Corto</label> | ||||
|           <input type="text" value={nombreCorto} onChange={e => setNombreCorto(e.target.value)} /> | ||||
|         </div> | ||||
|  | ||||
|         <div className="form-field"> | ||||
|           <label>Color</label> | ||||
|           <input type="color" value={color} onChange={e => setColor(e.target.value)} /> | ||||
|         </div> | ||||
|  | ||||
|         <button type="submit" disabled={isLoading}> | ||||
|           {isLoading ? 'Guardando...' : 'Guardar Partido'} | ||||
|         </button> | ||||
|       </form> | ||||
|       {error && <p style={{ color: 'red', marginTop: '0.5rem', textAlign: 'left' }}>{error}</p>} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -36,6 +36,23 @@ td button { | ||||
|     margin-right: 5px; | ||||
| } | ||||
|  | ||||
| .table-container { | ||||
|   max-height: 500px; /* Altura máxima antes de que aparezca el scroll */ | ||||
|   overflow-y: auto;  /* Habilita el scroll vertical cuando es necesario */ | ||||
|   border: 1px solid #ddd; | ||||
|   border-radius: 4px; | ||||
|   position: relative; /* Necesario para que 'sticky' funcione correctamente */ | ||||
| } | ||||
|  | ||||
| /* Hacemos que la cabecera de la tabla se quede fija en la parte superior */ | ||||
| .table-container thead th { | ||||
|   position: sticky; | ||||
|   top: 0; | ||||
|   z-index: 1; | ||||
|   /* El color de fondo es crucial para que no se vea el contenido que pasa por debajo */ | ||||
|   background-color: #f2f2f2;  | ||||
| } | ||||
|  | ||||
| .sortable-list-horizontal { | ||||
|   list-style: none; | ||||
|   padding: 8px; | ||||
|   | ||||
| @@ -1,146 +1,154 @@ | ||||
| // src/components/AgrupacionesManager.tsx | ||||
| // EN: src/components/AgrupacionesManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica, LogoAgrupacionCategoria } from '../types'; | ||||
| import type { AgrupacionPolitica, LogoAgrupacionCategoria, UpdateAgrupacionData } from '../types'; | ||||
| import { AddAgrupacionForm } from './AddAgrupacionForm'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const SENADORES_ID = 5; | ||||
| const DIPUTADOS_ID = 6; | ||||
| const CONCEJALES_ID = 7; | ||||
| const GLOBAL_ELECTION_ID = 0; | ||||
|  | ||||
| const ELECCION_OPTIONS = [ | ||||
|     { value: GLOBAL_ELECTION_ID, label: 'Global (Logo por Defecto)' }, | ||||
|     { value: 2, label: 'Elecciones Nacionales (Override General)' }, | ||||
|     { value: 1, label: 'Elecciones Provinciales (Override General)' } | ||||
| ]; | ||||
|  | ||||
| const sanitizeColor = (color: string | null | undefined): string => { | ||||
|     if (!color) return '#000000'; | ||||
|     return color.startsWith('#') ? color : `#${color}`; | ||||
| }; | ||||
|  | ||||
| export const AgrupacionesManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); | ||||
|     const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, { nombreCorto: string | null; color: string | null; }>>({}); | ||||
|     const [editedLogos, setEditedLogos] = useState<Record<string, string | null>>({}); | ||||
|  | ||||
|     const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, Partial<AgrupacionPolitica>>>({}); | ||||
|     const [editedLogos, setEditedLogos] = useState<LogoAgrupacionCategoria[]>([]); | ||||
|  | ||||
|     // Query 1: Obtener agrupaciones | ||||
|     const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|         queryKey: ['agrupaciones'], | ||||
|         queryFn: getAgrupaciones, | ||||
|         queryKey: ['agrupaciones'], queryFn: getAgrupaciones, | ||||
|     }); | ||||
|  | ||||
|     // Query 2: Obtener logos | ||||
|     const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({ | ||||
|         queryKey: ['logos'], | ||||
|         queryFn: getLogos, | ||||
|         queryKey: ['allLogos'], | ||||
|         queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()), | ||||
|     }); | ||||
|  | ||||
|     // Usamos useEffect para reaccionar cuando los datos de 'logos' se cargan o cambian. | ||||
|     useEffect(() => { | ||||
|         if (logos) { | ||||
|             setEditedLogos(logos); | ||||
|         } | ||||
|     }, [logos]); | ||||
|     const handleCreationSuccess = () => { | ||||
|         // Invalida la query de agrupaciones para forzar una actualización | ||||
|         queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); | ||||
|     }; | ||||
|  | ||||
|     // Usamos otro useEffect para reaccionar a los datos de 'agrupaciones'. | ||||
|     useEffect(() => { | ||||
|         if (agrupaciones) { | ||||
|             const initialEdits = Object.fromEntries(agrupaciones.map(a => [a.id, {}])); | ||||
|         if (agrupaciones.length > 0) { | ||||
|             const initialEdits = Object.fromEntries( | ||||
|                 agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }]) | ||||
|             ); | ||||
|             setEditedAgrupaciones(initialEdits); | ||||
|         } | ||||
|     }, [agrupaciones]); | ||||
|  | ||||
|     const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => { | ||||
|         setEditedAgrupaciones(prev => ({ | ||||
|             ...prev, | ||||
|             [id]: { ...prev[id], [field]: value } | ||||
|         })); | ||||
|     useEffect(() => { | ||||
|         if (logos) { | ||||
|             const logoMap = Object.fromEntries( | ||||
|                 logos | ||||
|                     // --- CORRECCIÓN CLAVE 1: Comprobar contra `null` en lugar de `0` --- | ||||
|                     .filter(l => l.categoriaId === 0 && l.ambitoGeograficoId === null) | ||||
|                     .map(l => [`${l.agrupacionPoliticaId}-${l.eleccionId}`, l.logoUrl]) | ||||
|             ); | ||||
|             setEditedLogos(logoMap); | ||||
|         } | ||||
|     }, [logos]); | ||||
|  | ||||
|     const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => { | ||||
|         setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } })); | ||||
|     }; | ||||
|  | ||||
|     const handleLogoChange = (agrupacionId: string, categoriaId: number, value: string) => { | ||||
|         setEditedLogos(prev => { | ||||
|             const newLogos = [...prev]; | ||||
|             const existing = newLogos.find(l => | ||||
|                 l.agrupacionPoliticaId === agrupacionId && | ||||
|                 l.categoriaId === categoriaId && | ||||
|                 l.ambitoGeograficoId == null | ||||
|             ); | ||||
|  | ||||
|             if (existing) { | ||||
|                 existing.logoUrl = value; | ||||
|             } else { | ||||
|                 newLogos.push({ | ||||
|                     id: 0, | ||||
|                     agrupacionPoliticaId: agrupacionId, | ||||
|                     categoriaId, | ||||
|                     logoUrl: value, | ||||
|                     ambitoGeograficoId: null | ||||
|                 }); | ||||
|             } | ||||
|             return newLogos; | ||||
|         }); | ||||
|     const handleLogoInputChange = (agrupacionId: string, value: string | null) => { | ||||
|         const key = `${agrupacionId}-${selectedEleccion.value}`; | ||||
|         setEditedLogos(prev => ({ ...prev, [key]: value })); | ||||
|     }; | ||||
|  | ||||
|     const handleSaveAll = async () => { | ||||
|         try { | ||||
|             const agrupacionPromises = Object.entries(editedAgrupaciones).map(([id, changes]) => { | ||||
|                 if (Object.keys(changes).length > 0) { | ||||
|                     const original = agrupaciones.find(a => a.id === id); | ||||
|                     if (original) { // Chequeo de seguridad | ||||
|                         return updateAgrupacion(id, { ...original, ...changes }); | ||||
|                     } | ||||
|                 } | ||||
|                 return Promise.resolve(); | ||||
|             const agrupacionPromises = agrupaciones.map(agrupacion => { | ||||
|                 const changes = editedAgrupaciones[agrupacion.id] || {}; | ||||
|                 const payload: UpdateAgrupacionData = { | ||||
|                     nombreCorto: changes.nombreCorto ?? agrupacion.nombreCorto, | ||||
|                     color: changes.color ?? agrupacion.color, | ||||
|                 }; | ||||
|                 return updateAgrupacion(agrupacion.id, payload); | ||||
|             }); | ||||
|  | ||||
|             const logoPromise = updateLogos(editedLogos); | ||||
|             // --- CORRECCIÓN CLAVE 2: Enviar `null` a la API en lugar de `0` --- | ||||
|             const logosPayload = Object.entries(editedLogos) | ||||
|                 .map(([key, logoUrl]) => { | ||||
|                     const [agrupacionPoliticaId, eleccionIdStr] = key.split('-'); | ||||
|                     return { id: 0, eleccionId: parseInt(eleccionIdStr), agrupacionPoliticaId, categoriaId: 0, logoUrl: logoUrl || null, ambitoGeograficoId: null }; | ||||
|                 }); | ||||
|  | ||||
|             const logoPromise = updateLogos(logosPayload); | ||||
|  | ||||
|             await Promise.all([...agrupacionPromises, logoPromise]); | ||||
|  | ||||
|             queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); | ||||
|             queryClient.invalidateQueries({ queryKey: ['logos'] }); | ||||
|  | ||||
|             await queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); | ||||
|             await queryClient.invalidateQueries({ queryKey: ['allLogos'] }); | ||||
|             alert('¡Todos los cambios han sido guardados!'); | ||||
|         } catch (err) { | ||||
|             console.error("Error al guardar todo:", err); | ||||
|             alert("Ocurrió un error al guardar los cambios."); | ||||
|         } | ||||
|         } catch (err) { console.error("Error al guardar todo:", err); alert("Ocurrió un error."); } | ||||
|     }; | ||||
|  | ||||
|     const getLogoValue = (agrupacionId: string): string => { | ||||
|         const key = `${agrupacionId}-${selectedEleccion.value}`; | ||||
|         return editedLogos[key] ?? ''; | ||||
|     }; | ||||
|  | ||||
|     const isLoading = isLoadingAgrupaciones || isLoadingLogos; | ||||
|  | ||||
|     const getLogoUrl = (agrupacionId: string, categoriaId: number) => { | ||||
|         return editedLogos.find(l => | ||||
|             l.agrupacionPoliticaId === agrupacionId && | ||||
|             l.categoriaId === categoriaId && | ||||
|             l.ambitoGeograficoId == null | ||||
|         )?.logoUrl || ''; | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Gestión de Agrupaciones y Logos</h3> | ||||
|             <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | ||||
|                 <h3>Gestión de Agrupaciones y Logos</h3> | ||||
|                 <div style={{ width: '350px', zIndex: 100 }}> | ||||
|                     <Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => setSelectedEleccion(opt!)} /> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             {isLoading ? <p>Cargando...</p> : ( | ||||
|                 <> | ||||
|                     <table> | ||||
|                         <thead> | ||||
|                             <tr> | ||||
|                                 <th>Nombre</th> | ||||
|                                 <th>Nombre Corto</th> | ||||
|                                 <th>Color</th> | ||||
|                                 <th>Logo Senadores</th> | ||||
|                                 <th>Logo Diputados</th> | ||||
|                                 <th>Logo Concejales</th> | ||||
|                             </tr> | ||||
|                         </thead> | ||||
|                         <tbody> | ||||
|                             {agrupaciones.map(agrupacion => ( | ||||
|                                 <tr key={agrupacion.id}> | ||||
|                                     <td>{agrupacion.nombre}</td> | ||||
|                                     <td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? agrupacion.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td> | ||||
|                                     <td><input type="color" value={editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color ?? '#000000'} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} /></td> | ||||
|                                     <td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, SENADORES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, SENADORES_ID, e.target.value)} /></td> | ||||
|                                     <td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, DIPUTADOS_ID)} onChange={(e) => handleLogoChange(agrupacion.id, DIPUTADOS_ID, e.target.value)} /></td> | ||||
|                                     <td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, CONCEJALES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, CONCEJALES_ID, e.target.value)} /></td> | ||||
|                     <div className="table-container"> | ||||
|                         <table> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th>Nombre</th> | ||||
|                                     <th>Nombre Corto</th> | ||||
|                                     <th>Color</th> | ||||
|                                     <th>Logo</th> | ||||
|                                 </tr> | ||||
|                             ))} | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 {agrupaciones.map(agrupacion => ( | ||||
|                                     <tr key={agrupacion.id}> | ||||
|                                         <td>({agrupacion.id}) {agrupacion.nombre}</td> | ||||
|                                         <td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td> | ||||
|                                         <td><input type="color" value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color)} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} /></td> | ||||
|                                         <td> | ||||
|                                             <input | ||||
|                                                 type="text" | ||||
|                                                 placeholder="URL..." | ||||
|                                                 value={getLogoValue(agrupacion.id)} | ||||
|                                                 onChange={(e) => handleLogoInputChange(agrupacion.id, e.target.value)} | ||||
|                                             /> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 ))} | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                     </div> | ||||
|                     <button onClick={handleSaveAll} style={{ marginTop: '1rem' }}> | ||||
|                         Guardar Todos los Cambios | ||||
|                     </button> | ||||
|                     <AddAgrupacionForm onSuccess={handleCreationSuccess} /> | ||||
|                 </> | ||||
|             )} | ||||
|         </div> | ||||
|   | ||||
| @@ -0,0 +1,117 @@ | ||||
| // src/components/BancasNacionalesManager.tsx | ||||
| import { useState } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService'; | ||||
| import type { Bancada, AgrupacionPolitica } from '../types'; | ||||
| import { OcupantesModal } from './OcupantesModal'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
| const camaras = ['diputados', 'senadores'] as const; | ||||
|  | ||||
| export const BancasNacionalesManager = () => { | ||||
|   const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados'); | ||||
|   const [modalVisible, setModalVisible] = useState(false); | ||||
|   const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null); | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ | ||||
|     queryKey: ['agrupaciones'], | ||||
|     queryFn: getAgrupaciones | ||||
|   }); | ||||
|  | ||||
|   const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({ | ||||
|     queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL], | ||||
|     queryFn: () => getBancadas(activeTab, ELECCION_ID_NACIONAL), | ||||
|   }); | ||||
|  | ||||
|   const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => { | ||||
|     const bancadaActual = bancadas.find(b => b.id === bancadaId); | ||||
|     if (!bancadaActual) return; | ||||
|  | ||||
|     const payload: UpdateBancadaData = { | ||||
|       agrupacionPoliticaId: nuevaAgrupacionId, | ||||
|       nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null, | ||||
|       fotoUrl: nuevaAgrupacionId ? (bancadaActual.ocupante?.fotoUrl ?? null) : null, | ||||
|       periodo: nuevaAgrupacionId ? (bancadaActual.ocupante?.periodo ?? null) : null, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       await updateBancada(bancadaId, payload); | ||||
|       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL] }); | ||||
|     } catch (err) { | ||||
|       alert("Error al guardar el cambio de agrupación."); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleOpenModal = (bancada: Bancada) => { | ||||
|     setBancadaSeleccionada(bancada); | ||||
|     setModalVisible(true); | ||||
|   }; | ||||
|  | ||||
|   if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas nacionales.</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Gestión de Bancas (Nacionales)</h3> | ||||
|       <p>Asigne partidos y ocupantes a las bancas del Congreso de la Nación.</p> | ||||
|  | ||||
|       <div className="chamber-tabs"> | ||||
|         {camaras.map(camara => ( | ||||
|           <button | ||||
|             key={camara} | ||||
|             className={activeTab === camara ? 'active' : ''} | ||||
|             onClick={() => setActiveTab(camara)} | ||||
|           > | ||||
|             {camara === 'diputados' ? 'Diputados Nacionales (257)' : 'Senadores Nacionales (72)'} | ||||
|           </button> | ||||
|         ))} | ||||
|       </div> | ||||
|  | ||||
|       {isLoading ? <p>Cargando bancas...</p> : ( | ||||
|         <div className="table-container"> | ||||
|           <table> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th style={{ width: '15%' }}>Banca #</th> | ||||
|                 <th style={{ width: '35%' }}>Partido Asignado</th> | ||||
|                 <th style={{ width: '30%' }}>Ocupante Actual</th> | ||||
|                 <th style={{ width: '20%' }}>Acciones</th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               {bancadas.map((bancada) => ( | ||||
|                 <tr key={bancada.id}> | ||||
|                   <td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td> | ||||
|                   <td> | ||||
|                     <select | ||||
|                       value={bancada.agrupacionPoliticaId || ''} | ||||
|                       onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)} | ||||
|                     > | ||||
|                       <option value="">-- Vacante --</option> | ||||
|                       {agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)} | ||||
|                     </select> | ||||
|                   </td> | ||||
|                   <td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td> | ||||
|                   <td> | ||||
|                     <button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}> | ||||
|                       Editar Ocupante | ||||
|                     </button> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               ))} | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {modalVisible && bancadaSeleccionada && ( | ||||
|         <OcupantesModal | ||||
|           bancada={bancadaSeleccionada} | ||||
|           onClose={() => setModalVisible(false)} | ||||
|           activeTab={activeTab} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,125 @@ | ||||
| // src/components/BancasPreviasManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getBancasPrevias, updateBancasPrevias, getAgrupaciones } from '../services/apiService'; | ||||
| import type { BancaPrevia, AgrupacionPolitica } from '../types'; | ||||
| import { TipoCamara } from '../types'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const BancasPreviasManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const [editedBancas, setEditedBancas] = useState<Record<string, Partial<BancaPrevia>>>({}); | ||||
|  | ||||
|     const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|         queryKey: ['agrupaciones'], | ||||
|         queryFn: getAgrupaciones, | ||||
|     }); | ||||
|  | ||||
|     const { data: bancasPrevias = [], isLoading: isLoadingBancas } = useQuery<BancaPrevia[]>({ | ||||
|         queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL], | ||||
|         queryFn: () => getBancasPrevias(ELECCION_ID_NACIONAL), | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (agrupaciones.length > 0) { | ||||
|             const initialData: Record<string, Partial<BancaPrevia>> = {}; | ||||
|             agrupaciones.forEach(agrupacion => { | ||||
|                 // Para Diputados | ||||
|                 const keyDip = `${agrupacion.id}-${TipoCamara.Diputados}`; | ||||
|                 const existingDip = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Diputados); | ||||
|                 initialData[keyDip] = { cantidad: existingDip?.cantidad || 0 }; | ||||
|  | ||||
|                 // Para Senadores | ||||
|                 const keySen = `${agrupacion.id}-${TipoCamara.Senadores}`; | ||||
|                 const existingSen = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Senadores); | ||||
|                 initialData[keySen] = { cantidad: existingSen?.cantidad || 0 }; | ||||
|             }); | ||||
|             setEditedBancas(initialData); | ||||
|         } | ||||
|     }, [agrupaciones, bancasPrevias]); | ||||
|  | ||||
|     const handleInputChange = (agrupacionId: string, camara: typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, value: string) => { | ||||
|         const key = `${agrupacionId}-${camara}`; | ||||
|         const cantidad = parseInt(value, 10); | ||||
|         setEditedBancas(prev => ({ | ||||
|             ...prev, | ||||
|             [key]: { ...prev[key], cantidad: isNaN(cantidad) ? 0 : cantidad } | ||||
|         })); | ||||
|     }; | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         const payload: BancaPrevia[] = Object.entries(editedBancas) | ||||
|             .map(([key, value]) => { | ||||
|                 const [agrupacionPoliticaId, camara] = key.split('-'); | ||||
|                 return { | ||||
|                     id: 0, | ||||
|                     eleccionId: ELECCION_ID_NACIONAL, | ||||
|                     agrupacionPoliticaId, | ||||
|                     camara: parseInt(camara) as typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, | ||||
|                     cantidad: value.cantidad || 0, | ||||
|                 }; | ||||
|             }) | ||||
|             .filter(b => b.cantidad > 0); | ||||
|  | ||||
|         try { | ||||
|             await updateBancasPrevias(ELECCION_ID_NACIONAL, payload); | ||||
|             queryClient.invalidateQueries({ queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL] }); | ||||
|             alert('Bancas previas guardadas con éxito.'); | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             alert('Error al guardar las bancas previas.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const totalDiputados = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Diputados}`) ? sum + (value.cantidad || 0) : sum, 0); | ||||
|     const totalSenadores = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Senadores}`) ? sum + (value.cantidad || 0) : sum, 0); | ||||
|  | ||||
|     const isLoading = isLoadingAgrupaciones || isLoadingBancas; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Gestión de Bancas Previas (Composición Nacional)</h3> | ||||
|             <p>Define cuántas bancas retiene cada partido antes de la elección. Estos son los escaños que **no** están en juego.</p> | ||||
|             {isLoading ? <p>Cargando...</p> : ( | ||||
|                 <> | ||||
|                     <div className="table-container"> | ||||
|                         <table> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th>Agrupación Política</th> | ||||
|                                     <th>Bancas Previas Diputados (Total: {totalDiputados} / 130)</th> | ||||
|                                     <th>Bancas Previas Senadores (Total: {totalSenadores} / 48)</th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 {agrupaciones.map(agrupacion => ( | ||||
|                                     <tr key={agrupacion.id}> | ||||
|                                         <td>({agrupacion.id}) {agrupacion.nombre}</td> | ||||
|                                         <td> | ||||
|                                             <input | ||||
|                                                 type="number" | ||||
|                                                 min="0" | ||||
|                                                 value={editedBancas[`${agrupacion.id}-${TipoCamara.Diputados}`]?.cantidad || 0} | ||||
|                                                 onChange={e => handleInputChange(agrupacion.id, TipoCamara.Diputados, e.target.value)} | ||||
|                                             /> | ||||
|                                         </td> | ||||
|                                         <td> | ||||
|                                             <input | ||||
|                                                 type="number" | ||||
|                                                 min="0" | ||||
|                                                 value={editedBancas[`${agrupacion.id}-${TipoCamara.Senadores}`]?.cantidad || 0} | ||||
|                                                 onChange={e => handleInputChange(agrupacion.id, TipoCamara.Senadores, e.target.value)} | ||||
|                                             /> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 ))} | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                     </div> | ||||
|                     <button onClick={handleSave} style={{ marginTop: '1rem' }}>Guardar Bancas Previas</button> | ||||
|                 </> | ||||
|             )} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,4 +1,4 @@ | ||||
| // src/components/BancasManager.tsx
 | ||||
| // src/components/BancasProvincialesManager.tsx
 | ||||
| import { useState } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService'; | ||||
| @@ -6,9 +6,10 @@ import type { Bancada, AgrupacionPolitica } from '../types'; | ||||
| import { OcupantesModal } from './OcupantesModal'; | ||||
| import './AgrupacionesManager.css'; | ||||
| 
 | ||||
| const ELECCION_ID_PROVINCIAL = 1; | ||||
| const camaras = ['diputados', 'senadores'] as const; | ||||
| 
 | ||||
| export const BancasManager = () => { | ||||
| export const BancasProvincialesManager = () => { | ||||
|   const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados'); | ||||
|   const [modalVisible, setModalVisible] = useState(false); | ||||
|   const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null); | ||||
| @@ -19,16 +20,18 @@ export const BancasManager = () => { | ||||
|     queryFn: getAgrupaciones | ||||
|   }); | ||||
| 
 | ||||
|   // --- CORRECCIÓN CLAVE ---
 | ||||
|   // 1. La queryKey ahora incluye el eleccionId para ser única.
 | ||||
|   // 2. La función queryFn ahora pasa el ELECCION_ID_PROVINCIAL a getBancadas.
 | ||||
|   const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({ | ||||
|     queryKey: ['bancadas', activeTab], | ||||
|     queryFn: () => getBancadas(activeTab), | ||||
|     queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL], | ||||
|     queryFn: () => getBancadas(activeTab, ELECCION_ID_PROVINCIAL), | ||||
|   }); | ||||
| 
 | ||||
|   const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => { | ||||
|     const bancadaActual = bancadas.find(b => b.id === bancadaId); | ||||
|     if (!bancadaActual) return; | ||||
| 
 | ||||
|     // Si se desasigna el partido (vacante), también se limpia el ocupante
 | ||||
|     const payload: UpdateBancadaData = { | ||||
|       agrupacionPoliticaId: nuevaAgrupacionId, | ||||
|       nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null, | ||||
| @@ -38,7 +41,7 @@ export const BancasManager = () => { | ||||
| 
 | ||||
|     try { | ||||
|       await updateBancada(bancadaId, payload); | ||||
|       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab] }); | ||||
|       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL] }); | ||||
|     } catch (err) { | ||||
|       alert("Error al guardar el cambio de agrupación."); | ||||
|     } | ||||
| @@ -49,12 +52,12 @@ export const BancasManager = () => { | ||||
|     setModalVisible(true); | ||||
|   }; | ||||
| 
 | ||||
|   if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas.</p>; | ||||
|   if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas provinciales.</p>; | ||||
| 
 | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h2>Gestión de Ocupación de Bancas</h2> | ||||
|       <p>Asigne a cada banca física un partido político y, opcionalmente, los datos de la persona que la ocupa.</p> | ||||
|       <h3>Gestión de Bancas (Provinciales)</h3> | ||||
|       <p>Asigne partidos y ocupantes a las bancas de la legislatura provincial.</p> | ||||
| 
 | ||||
|       <div className="chamber-tabs"> | ||||
|         {camaras.map(camara => ( | ||||
| @@ -63,7 +66,7 @@ export const BancasManager = () => { | ||||
|             className={activeTab === camara ? 'active' : ''} | ||||
|             onClick={() => setActiveTab(camara)} | ||||
|           > | ||||
|             {camara === 'diputados' ? 'Cámara de Diputados' : 'Cámara de Senadores'} | ||||
|             {camara === 'diputados' ? 'Diputados Provinciales (92)' : 'Senadores Provinciales (46)'} | ||||
|           </button> | ||||
|         ))} | ||||
|       </div> | ||||
| @@ -81,32 +84,19 @@ export const BancasManager = () => { | ||||
|           <tbody> | ||||
|             {bancadas.map((bancada) => ( | ||||
|               <tr key={bancada.id}> | ||||
|                 {/* Usamos el NumeroBanca para la etiqueta visual */} | ||||
|                 <td> | ||||
|                   {`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`} | ||||
|                   {((activeTab === 'diputados' && bancada.numeroBanca === 92) || | ||||
|                     (activeTab === 'senadores' && bancada.numeroBanca === 46)) && ( | ||||
|                       <span style={{ marginLeft: '8px', fontSize: '0.8em', color: '#666', fontStyle: 'italic' }}> | ||||
|                         (Presidencia) | ||||
|                       </span> | ||||
|                     )} | ||||
|                 </td> | ||||
|                 <td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td> | ||||
|                 <td> | ||||
|                   <select | ||||
|                     value={bancada.agrupacionPoliticaId || ''} | ||||
|                     onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)} | ||||
|                   > | ||||
|                     <option value="">-- Vacante --</option> | ||||
|                     {agrupaciones.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)} | ||||
|                     {agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)} | ||||
|                   </select> | ||||
|                 </td> | ||||
|                 <td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td> | ||||
|                 <td> | ||||
|                   <button | ||||
|                     // El botón se habilita solo si hay un partido asignado a la banca
 | ||||
|                     disabled={!bancada.agrupacionPoliticaId} | ||||
|                     onClick={() => handleOpenModal(bancada)} | ||||
|                   > | ||||
|                   <button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}> | ||||
|                     Editar Ocupante | ||||
|                   </button> | ||||
|                 </td> | ||||
| @@ -0,0 +1,120 @@ | ||||
| // src/components/CandidatoOverridesManager.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService'; | ||||
| import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types'; | ||||
| import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias'; | ||||
|  | ||||
| const ELECCION_OPTIONS = [ | ||||
|     { value: 0, label: 'General (Todas las elecciones)' }, | ||||
|     { value: 2, label: 'Elecciones Nacionales' }, | ||||
|     { value: 1, label: 'Elecciones Provinciales' } | ||||
| ]; | ||||
|  | ||||
| const AMBITO_LEVEL_OPTIONS = [ | ||||
|     { value: 'general', label: 'General (Toda la elección)' }, | ||||
|     { value: 'provincia', label: 'Por Provincia' }, | ||||
|     { value: 'municipio', label: 'Por Municipio' } | ||||
| ]; | ||||
|  | ||||
| export const CandidatoOverridesManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); | ||||
|     const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]); | ||||
|     const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null); | ||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null); | ||||
|     const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||
|     const [nombreCandidato, setNombreCandidato] = useState(''); | ||||
|  | ||||
|     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||
|  | ||||
|     const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ | ||||
|         queryKey: ['allCandidatos'], | ||||
|         queryFn: () => Promise.all([getCandidatos(0), getCandidatos(1), getCandidatos(2)]).then(res => res.flat()), | ||||
|     }); | ||||
|  | ||||
|     const categoriaOptions = useMemo(() => { | ||||
|         if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS; | ||||
|         if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS; | ||||
|         return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS]; | ||||
|     }, [selectedEleccion]); | ||||
|  | ||||
|     const getAmbitoId = () => { | ||||
|         if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id); | ||||
|         if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id); | ||||
|         return null; | ||||
|     }; | ||||
|  | ||||
|     const currentCandidato = useMemo(() => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||
|         const ambitoId = getAmbitoId(); | ||||
|         return candidatos.find(c => | ||||
|             c.eleccionId === selectedEleccion.value && | ||||
|             c.ambitoGeograficoId === ambitoId && | ||||
|             c.agrupacionPoliticaId === selectedAgrupacion.id && | ||||
|             c.categoriaId === selectedCategoria.value | ||||
|         )?.nombreCandidato || ''; | ||||
|     }, [candidatos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||
|  | ||||
|     useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return; | ||||
|         const newCandidatoEntry: CandidatoOverride = { | ||||
|             id: 0, | ||||
|             eleccionId: selectedEleccion.value, | ||||
|             agrupacionPoliticaId: selectedAgrupacion.id, | ||||
|             categoriaId: selectedCategoria.value, | ||||
|             ambitoGeograficoId: getAmbitoId(), | ||||
|             nombreCandidato: nombreCandidato.trim() || null | ||||
|         }; | ||||
|         try { | ||||
|             await updateCandidatos([newCandidatoEntry]); | ||||
|             queryClient.invalidateQueries({ queryKey: ['allCandidatos'] }); | ||||
|             alert('Override de candidato guardado.'); | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             alert('Error al guardar el override del candidato.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Overrides de Nombres de Candidatos</h3> | ||||
|             <p>Configure un nombre de candidato específico para un partido en un contexto determinado.</p> | ||||
|             <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}> | ||||
|                 <Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} /> | ||||
|                 <Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." /> | ||||
|                 <Select | ||||
|                     options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} | ||||
|                     getOptionValue={opt => opt.id} | ||||
|                     getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`} | ||||
|                     value={selectedAgrupacion} | ||||
|                     onChange={setSelectedAgrupacion} | ||||
|                     placeholder="Seleccione Agrupación..." | ||||
|                 /> | ||||
|                 <Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} /> | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." /> | ||||
|                 ) : <div />} | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} /> | ||||
|                 ) : <div />} | ||||
|             </div> | ||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>Nombre del Candidato</label> | ||||
|                     <input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} /> | ||||
|                 </div> | ||||
|                 <button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,101 @@ | ||||
| // src/components/ConfiguracionNacional.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import { getAgrupaciones, getConfiguracion, updateConfiguracion } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| export const ConfiguracionNacional = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|  | ||||
|     const [presidenciaDiputadosId, setPresidenciaDiputadosId] = useState<string>(''); | ||||
|     const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>(''); | ||||
|     const [modoOficialActivo, setModoOficialActivo] = useState(false); | ||||
|     const [diputadosTipoBanca, setDiputadosTipoBanca] = useState<'ganada' | 'previa'>('ganada'); | ||||
|     // El estado para el tipo de banca del senado ya no es necesario para la UI,  | ||||
|     // pero lo mantenemos para no romper el handleSave. | ||||
|     const [senadoTipoBanca, setSenadoTipoBanca] = useState<'ganada' | 'previa'>('ganada'); | ||||
|  | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const loadInitialData = async () => { | ||||
|             try { | ||||
|                 setLoading(true); | ||||
|                 const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]); | ||||
|                 setAgrupaciones(agrupacionesData); | ||||
|                 setPresidenciaDiputadosId(configData.PresidenciaDiputadosNacional || ''); | ||||
|                 setPresidenciaSenadoId(configData.PresidenciaSenadoNacional || ''); | ||||
|                 setModoOficialActivo(configData.UsarDatosOficialesNacionales === 'true'); | ||||
|                 setDiputadosTipoBanca(configData.PresidenciaDiputadosNacional_TipoBanca === 'previa' ? 'previa' : 'ganada'); | ||||
|                 setSenadoTipoBanca(configData.PresidenciaSenadoNacional_TipoBanca === 'previa' ? 'previa' : 'ganada'); | ||||
|             } catch (err) { console.error("Error al cargar datos de configuración nacional:", err); } | ||||
|             finally { setLoading(false); } | ||||
|         }; | ||||
|         loadInitialData(); | ||||
|     }, []); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         try { | ||||
|             await updateConfiguracion({ | ||||
|                 "PresidenciaDiputadosNacional": presidenciaDiputadosId, | ||||
|                 "PresidenciaSenadoNacional": presidenciaSenadoId, | ||||
|                 "UsarDatosOficialesNacionales": modoOficialActivo.toString(), | ||||
|                 "PresidenciaDiputadosNacional_TipoBanca": diputadosTipoBanca, | ||||
|                 // Aunque no se muestre, guardamos el valor para consistencia | ||||
|                 "PresidenciaSenadoNacional_TipoBanca": senadoTipoBanca, | ||||
|             }); | ||||
|             queryClient.invalidateQueries({ queryKey: ['composicionNacional'] }); | ||||
|             alert('Configuración nacional guardada.'); | ||||
|         } catch { alert('Error al guardar.'); } | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <div className="admin-module"><p>Cargando...</p></div>; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Configuración de Widgets Nacionales</h3> | ||||
|  | ||||
|             <div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}> | ||||
|                 {/* Columna Diputados */} | ||||
|                 <div style={{ flex: 1, borderRight: '1px solid #ccc', paddingRight: '1rem' }}> | ||||
|                     <label htmlFor="presidencia-diputados-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}> | ||||
|                         Presidencia Cámara de Diputados | ||||
|                     </label> | ||||
|                     <p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}> | ||||
|                         Este escaño es parte de los 257 diputados y se descuenta del total del partido. | ||||
|                     </p> | ||||
|                     <select id="presidencia-diputados-nacional" value={presidenciaDiputadosId} onChange={e => setPresidenciaDiputadosId(e.target.value)} style={{ width: '100%', padding: '8px', marginBottom: '0.5rem' }}> | ||||
|                         <option value="">-- No Asignado --</option> | ||||
|                         {agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))} | ||||
|                     </select> | ||||
|                     {presidenciaDiputadosId && ( | ||||
|                         <div> | ||||
|                             <label><input type="radio" value="ganada" checked={diputadosTipoBanca === 'ganada'} onChange={() => setDiputadosTipoBanca('ganada')} /> Descontar de Banca Ganada</label> | ||||
|                             <label style={{ marginLeft: '1rem' }}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label> | ||||
|                         </div> | ||||
|                     )} | ||||
|                 </div> | ||||
|  | ||||
|                 {/* Columna Senadores */} | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label htmlFor="presidencia-senado-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}> | ||||
|                         Presidencia Senado (Vicepresidente) | ||||
|                     </label> | ||||
|                     <p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}> | ||||
|                         Este escaño es adicional a los 72 senadores y no se descuenta del total del partido. | ||||
|                     </p> | ||||
|                     <select id="presidencia-senado-nacional" value={presidenciaSenadoId} onChange={e => setPresidenciaSenadoId(e.target.value)} style={{ width: '100%', padding: '8px' }}> | ||||
|                         <option value="">-- No Asignado --</option> | ||||
|                         {agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))} | ||||
|                     </select> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <button onClick={handleSave} style={{ marginTop: '1.5rem' }}> | ||||
|                 Guardar Configuración | ||||
|             </button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,34 +1,89 @@ | ||||
| // src/components/DashboardPage.tsx | ||||
| import { useAuth } from '../context/AuthContext'; | ||||
| import { AgrupacionesManager } from './AgrupacionesManager'; | ||||
| import { OrdenDiputadosManager } from './OrdenDiputadosManager'; | ||||
| import { OrdenSenadoresManager } from './OrdenSenadoresManager'; | ||||
| import { ConfiguracionGeneral } from './ConfiguracionGeneral'; | ||||
| import { BancasManager } from './BancasManager'; | ||||
| //import { OrdenDiputadosManager } from './OrdenDiputadosManager'; | ||||
| //import { OrdenSenadoresManager } from './OrdenSenadoresManager'; | ||||
| //import { ConfiguracionGeneral } from './ConfiguracionGeneral'; | ||||
| import { LogoOverridesManager } from './LogoOverridesManager'; | ||||
| import { CandidatoOverridesManager } from './CandidatoOverridesManager'; | ||||
| import { WorkerManager } from './WorkerManager'; | ||||
| import { ConfiguracionNacional } from './ConfiguracionNacional'; | ||||
| import { BancasPreviasManager } from './BancasPreviasManager'; | ||||
| import { OrdenDiputadosNacionalesManager } from './OrdenDiputadosNacionalesManager'; | ||||
| import { OrdenSenadoresNacionalesManager } from './OrdenSenadoresNacionalesManager'; | ||||
| //import { BancasProvincialesManager } from './BancasProvincialesManager'; | ||||
| //import { BancasNacionalesManager } from './BancasNacionalesManager'; | ||||
|  | ||||
|  | ||||
| export const DashboardPage = () => { | ||||
|     const { logout } = useAuth(); | ||||
|  | ||||
|     const sectionStyle = { | ||||
|         border: '1px solid #dee2e6', | ||||
|         borderRadius: '8px', | ||||
|         padding: '1.5rem', | ||||
|         marginBottom: '2rem', | ||||
|         backgroundColor: '#f8f9fa' | ||||
|     }; | ||||
|  | ||||
|     const sectionTitleStyle = { | ||||
|         marginTop: 0, | ||||
|         borderBottom: '2px solid #007bff', | ||||
|         paddingBottom: '0.5rem', | ||||
|         marginBottom: '1.5rem', | ||||
|         color: '#007bff' | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div style={{ padding: '1rem 2rem' }}> | ||||
|             <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '2px solid #eee', paddingBottom: '1rem' }}> | ||||
|             <header style={{ /* ... */ }}> | ||||
|                 <h1>Panel de Administración Electoral</h1> | ||||
|                 <button onClick={logout}>Cerrar Sesión</button> | ||||
|             </header> | ||||
|             <main style={{ marginTop: '2rem' }}>    | ||||
|                 <AgrupacionesManager /> | ||||
|                 <LogoOverridesManager /> | ||||
|                 <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}> | ||||
|                     <div style={{ flex: '1 1 400px' }}> | ||||
|                         <OrdenDiputadosManager /> | ||||
|                     </div> | ||||
|                     <div style={{ flex: '1 1 400px' }}> | ||||
|                         <OrdenSenadoresManager /> | ||||
|                     </div> | ||||
|              | ||||
|             <main style={{ marginTop: '2rem' }}> | ||||
|  | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2 style={sectionTitleStyle}>Configuración Global</h2> | ||||
|                     <AgrupacionesManager /> | ||||
|                     <LogoOverridesManager /> | ||||
|                     <CandidatoOverridesManager /> | ||||
|                 </div> | ||||
|                  | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2 style={sectionTitleStyle}>Gestión de Elecciones Nacionales</h2> | ||||
|                     <ConfiguracionNacional /> | ||||
|                     <BancasPreviasManager /> | ||||
|                     <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}> | ||||
|                         <div style={{ flex: '1 1 400px' }}> | ||||
|                             <OrdenDiputadosNacionalesManager /> | ||||
|                         </div> | ||||
|                         <div style={{ flex: '1 1 400px' }}> | ||||
|                             <OrdenSenadoresNacionalesManager /> | ||||
|                         </div> | ||||
|                     </div>                     | ||||
|                    {/* <BancasNacionalesManager /> */} | ||||
|                 </div> | ||||
|                  | ||||
|                 {/* | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2 style={sectionTitleStyle}>Gestión de Elecciones Provinciales</h2> | ||||
|                     <ConfiguracionGeneral /> | ||||
|                     <BancasProvincialesManager /> | ||||
|                     <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}> | ||||
|                         <div style={{ flex: '1 1 400px' }}> | ||||
|                             <OrdenDiputadosManager /> | ||||
|                         </div> | ||||
|                         <div style={{ flex: '1 1 400px' }}> | ||||
|                             <OrdenSenadoresManager /> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div>*/} | ||||
|  | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2 style={sectionTitleStyle}>Gestión de Workers y Sistema</h2> | ||||
|                     <WorkerManager /> | ||||
|                 </div> | ||||
|                 <ConfiguracionGeneral />  | ||||
|                 <BancasManager /> | ||||
|             </main> | ||||
|         </div> | ||||
|     ); | ||||
|   | ||||
							
								
								
									
										72
									
								
								Elecciones-Web/frontend-admin/src/components/FormStyles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,72 @@ | ||||
| /* src/components/FormStyles.css */ | ||||
|  | ||||
| .add-entity-form-container { | ||||
|     border-top: 2px solid #007bff; | ||||
|     padding-top: 1.5rem; | ||||
|     margin-top: 2rem; | ||||
| } | ||||
|  | ||||
| .add-entity-form-container h4 { | ||||
|     margin-top: 0; | ||||
|     margin-bottom: 1rem; | ||||
|     color: #333; | ||||
| } | ||||
|  | ||||
| .add-entity-form { | ||||
|     display: grid; | ||||
|     /* Usamos grid para un control preciso de las columnas */ | ||||
|     grid-template-columns: 3fr 2fr 0.5fr auto; | ||||
|     gap: 1rem; | ||||
|     align-items: flex-end; /* Alinea los elementos en la parte inferior de la celda */ | ||||
| } | ||||
|  | ||||
| .form-field { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     margin-right: 15px; | ||||
| } | ||||
|  | ||||
| .form-field label { | ||||
|     font-size: 0.85rem; | ||||
|     font-weight: 500; | ||||
|     margin-bottom: 0.25rem; | ||||
|     color: #555; | ||||
|     text-align: left; | ||||
| } | ||||
|  | ||||
| .form-field input[type="text"] { | ||||
|     padding: 8px; | ||||
|     border: 1px solid #ccc; | ||||
|     border-radius: 4px; | ||||
|     font-size: 1rem; | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .form-field input[type="color"] { | ||||
|     height: 38px; /* Misma altura que los inputs de texto */ | ||||
|     width: 100%; | ||||
|     border: 1px solid #ccc; | ||||
|     border-radius: 4px; | ||||
|     padding: 4px; /* Padding interno para el color */ | ||||
| } | ||||
|  | ||||
| .add-entity-form button { | ||||
|     padding: 8px 16px; | ||||
|     height: 38px; /* Misma altura que los inputs */ | ||||
|     border: none; | ||||
|     background-color: #28a745; /* Un color verde para la acción de "crear" */ | ||||
|     color: white; | ||||
|     font-weight: bold; | ||||
|     border-radius: 4px; | ||||
|     cursor: pointer; | ||||
|     transition: background-color 0.2s; | ||||
| } | ||||
|  | ||||
| .add-entity-form button:hover:not(:disabled) { | ||||
|     background-color: #218838; | ||||
| } | ||||
|  | ||||
| .add-entity-form button:disabled { | ||||
|     background-color: #6c757d; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
| @@ -2,81 +2,119 @@ | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService'; | ||||
| import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria } from '../types'; | ||||
| import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService'; | ||||
| import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria, ProvinciaSimple } from '../types'; | ||||
| import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias'; | ||||
|  | ||||
| // --- AÑADIMOS LAS CATEGORÍAS PARA EL SELECTOR --- | ||||
| const CATEGORIAS_OPTIONS = [ | ||||
|     { value: 5, label: 'Senadores' }, | ||||
|     { value: 6, label: 'Diputados' }, | ||||
|     { value: 7, label: 'Concejales' } | ||||
| const ELECCION_OPTIONS = [ | ||||
|     { value: 0, label: 'General (Todas las elecciones)' }, | ||||
|     { value: 2, label: 'Elecciones Nacionales' }, | ||||
|     { value: 1, label: 'Elecciones Provinciales' } | ||||
| ]; | ||||
|  | ||||
| const AMBITO_LEVEL_OPTIONS = [ | ||||
|     { value: 'general', label: 'General (Toda la elección)' }, | ||||
|     { value: 'provincia', label: 'Por Provincia' }, | ||||
|     { value: 'municipio', label: 'Por Municipio' } | ||||
| ]; | ||||
|  | ||||
| export const LogoOverridesManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||
|     const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({ queryKey: ['logos'], queryFn: getLogos }); | ||||
|  | ||||
|     // --- NUEVO ESTADO PARA LA CATEGORÍA --- | ||||
|     const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); | ||||
|     const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]); | ||||
|     const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null); | ||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null); | ||||
|     const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); | ||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null); | ||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||
|     const [logoUrl, setLogoUrl] = useState(''); | ||||
|  | ||||
|     const municipioOptions = useMemo(() => municipios.map(m => ({ value: m.id, label: m.nombre })), [municipios]); | ||||
|     const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]); | ||||
|     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||
|  | ||||
|     const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({ | ||||
|         queryKey: ['allLogos'], | ||||
|         queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()), | ||||
|     }); | ||||
|  | ||||
|     const categoriaOptions = useMemo(() => { | ||||
|         if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS; | ||||
|         if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS; | ||||
|         return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS]; | ||||
|     }, [selectedEleccion]); | ||||
|  | ||||
|     const getAmbitoId = () => { | ||||
|         if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id); | ||||
|         if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id); | ||||
|         return null; | ||||
|     }; | ||||
|  | ||||
|     const currentLogo = useMemo(() => { | ||||
|         // La búsqueda ahora depende de los 3 selectores | ||||
|         if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return ''; | ||||
|         return logos.find(l =>  | ||||
|             l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&  | ||||
|             l.agrupacionPoliticaId === selectedAgrupacion.value && | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||
|         const ambitoId = getAmbitoId(); | ||||
|  | ||||
|         return logos.find(l => | ||||
|             l.eleccionId === selectedEleccion.value && | ||||
|             l.ambitoGeograficoId === ambitoId && | ||||
|             l.agrupacionPoliticaId === selectedAgrupacion.id && | ||||
|             l.categoriaId === selectedCategoria.value | ||||
|         )?.logoUrl || ''; | ||||
|     }, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||
|      | ||||
|     useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]); | ||||
|     }, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||
|  | ||||
|     useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return; | ||||
|         if (!selectedAgrupacion || !selectedCategoria) { | ||||
|             alert("Por favor, seleccione una agrupación y una categoría."); | ||||
|             return; | ||||
|         } | ||||
|         const newLogoEntry: LogoAgrupacionCategoria = { | ||||
|             id: 0, | ||||
|             agrupacionPoliticaId: selectedAgrupacion.value, | ||||
|             eleccionId: selectedEleccion.value, | ||||
|             agrupacionPoliticaId: selectedAgrupacion.id, | ||||
|             categoriaId: selectedCategoria.value, | ||||
|             ambitoGeograficoId: parseInt(selectedMunicipio.value), | ||||
|             logoUrl: logoUrl || null | ||||
|             ambitoGeograficoId: getAmbitoId(), | ||||
|             logoUrl: logoUrl.trim() || null | ||||
|         }; | ||||
|         try { | ||||
|             await updateLogos([newLogoEntry]); | ||||
|             queryClient.invalidateQueries({ queryKey: ['logos'] }); | ||||
|             queryClient.invalidateQueries({ queryKey: ['allLogos'] }); | ||||
|             alert('Override de logo guardado.'); | ||||
|         } catch { alert('Error al guardar.'); } | ||||
|     }; | ||||
|      | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Overrides de Logos por Municipio y Categoría</h3> | ||||
|             <p>Configure una imagen específica para un partido en un municipio y categoría determinados.</p> | ||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end' }}> | ||||
|             <h3>Overrides de Logos</h3> | ||||
|             <p>Configure una imagen específica para un partido en un contexto determinado.</p> | ||||
|             <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}> | ||||
|                 <Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} /> | ||||
|                 <Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." /> | ||||
|                 <Select | ||||
|                     options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} | ||||
|                     getOptionValue={opt => opt.id} | ||||
|                     getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`} | ||||
|                     value={selectedAgrupacion} | ||||
|                     onChange={setSelectedAgrupacion} | ||||
|                     placeholder="Seleccione Agrupación..." | ||||
|                 /> | ||||
|                 <Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} /> | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." /> | ||||
|                 ) : <div />} | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} /> | ||||
|                 ) : <div />} | ||||
|             </div> | ||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>Categoría</label> | ||||
|                     <Select options={CATEGORIAS_OPTIONS} value={selectedCategoria} onChange={setSelectedCategoria} isClearable placeholder="Seleccione..."/> | ||||
|                 </div> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>Municipio</label> | ||||
|                     <Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="Seleccione..."/> | ||||
|                 </div> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>Agrupación</label> | ||||
|                     <Select options={agrupacionOptions} value={selectedAgrupacion} onChange={setSelectedAgrupacion} isClearable placeholder="Seleccione..."/> | ||||
|                 </div> | ||||
|                 <div style={{ flex: 2 }}> | ||||
|                     <label>URL del Logo Específico</label> | ||||
|                     <input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria} /> | ||||
|                     <input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} /> | ||||
|                 </div> | ||||
|                 <button onClick={handleSave} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria}>Guardar</button> | ||||
|                 <button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
|   | ||||
| @@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos | ||||
| const updateOrdenDiputadosApi = async (ids: string[]) => { | ||||
|   const token = localStorage.getItem('admin-jwt-token'); | ||||
|   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-diputados', { | ||||
|       method: 'PUT', | ||||
|       headers: { | ||||
|           'Content-Type': 'application/json', | ||||
|           'Authorization': `Bearer ${token}` | ||||
|       }, | ||||
|       body: JSON.stringify(ids) | ||||
|     method: 'PUT', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': `Bearer ${token}` | ||||
|     }, | ||||
|     body: JSON.stringify(ids) | ||||
|   }); | ||||
|   if (!response.ok) { | ||||
|     throw new Error("Failed to save Diputados order"); | ||||
| @@ -38,77 +38,77 @@ const updateOrdenDiputadosApi = async (ids: string[]) => { | ||||
| }; | ||||
|  | ||||
| export const OrdenDiputadosManager = () => { | ||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const sensors = useSensors( | ||||
|       useSensor(PointerSensor), | ||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|     ); | ||||
|   const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const sensors = useSensors( | ||||
|     useSensor(PointerSensor), | ||||
|     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|   ); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchAndSortAgrupaciones = async () => { | ||||
|             setLoading(true); | ||||
|             try { | ||||
|                 const data = await getAgrupaciones(); | ||||
|                 // Ordenar por el orden de Diputados. Los nulos van al final. | ||||
|                 data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999)); | ||||
|                 setAgrupaciones(data); | ||||
|             } catch (error) { | ||||
|                 console.error("Failed to fetch agrupaciones for Diputados:", error); | ||||
|             } finally { | ||||
|                 setLoading(false); | ||||
|             } | ||||
|         }; | ||||
|         fetchAndSortAgrupaciones(); | ||||
|     }, []); | ||||
|  | ||||
|     const handleDragEnd = (event: DragEndEvent) => { | ||||
|       const { active, over } = event; | ||||
|       if (over && active.id !== over.id) { | ||||
|         setAgrupaciones((items) => { | ||||
|           const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|           const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|           return arrayMove(items, oldIndex, newIndex); | ||||
|         }); | ||||
|   useEffect(() => { | ||||
|     const fetchAndSortAgrupaciones = async () => { | ||||
|       setLoading(true); | ||||
|       try { | ||||
|         const data = await getAgrupaciones(); | ||||
|         // Ordenar por el orden de Diputados. Los nulos van al final. | ||||
|         data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999)); | ||||
|         setAgrupaciones(data); | ||||
|       } catch (error) { | ||||
|         console.error("Failed to fetch agrupaciones for Diputados:", error); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|     fetchAndSortAgrupaciones(); | ||||
|   }, []); | ||||
|  | ||||
|     const handleSaveOrder = async () => { | ||||
|         const idsOrdenados = agrupaciones.map(a => a.id); | ||||
|         try { | ||||
|             await updateOrdenDiputadosApi(idsOrdenados); | ||||
|             alert('Orden de Diputados guardado con éxito!'); | ||||
|         } catch (error) { | ||||
|             alert('Error al guardar el orden de Diputados.'); | ||||
|         } | ||||
|     }; | ||||
|   const handleDragEnd = (event: DragEndEvent) => { | ||||
|     const { active, over } = event; | ||||
|     if (over && active.id !== over.id) { | ||||
|       setAgrupaciones((items) => { | ||||
|         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|         const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|         return arrayMove(items, oldIndex, newIndex); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|     if (loading) return <p>Cargando orden de Diputados...</p>; | ||||
|   const handleSaveOrder = async () => { | ||||
|     const idsOrdenados = agrupaciones.map(a => a.id); | ||||
|     try { | ||||
|       await updateOrdenDiputadosApi(idsOrdenados); | ||||
|       alert('Orden de Diputados guardado con éxito!'); | ||||
|     } catch (error) { | ||||
|       alert('Error al guardar el orden de Diputados.'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Ordenar Agrupaciones (Diputados)</h3> | ||||
|             <p>Arrastre para reordenar.</p> | ||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|             <DndContext | ||||
|               sensors={sensors} | ||||
|               collisionDetection={closestCenter} | ||||
|               onDragEnd={handleDragEnd} | ||||
|             > | ||||
|               <SortableContext | ||||
|                 items={agrupaciones.map(a => a.id)} | ||||
|                 strategy={horizontalListSortingStrategy} | ||||
|               > | ||||
|                 <ul className="sortable-list-horizontal"> | ||||
|                   {agrupaciones.map(agrupacion => ( | ||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button> | ||||
|         </div> | ||||
|     ); | ||||
|   if (loading) return <p>Cargando orden de Diputados...</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Ordenar Agrupaciones (Diputados)</h3> | ||||
|       <p>Arrastre para reordenar.</p> | ||||
|       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|       <DndContext | ||||
|         sensors={sensors} | ||||
|         collisionDetection={closestCenter} | ||||
|         onDragEnd={handleDragEnd} | ||||
|       > | ||||
|         <SortableContext | ||||
|           items={agrupaciones.map(a => a.id)} | ||||
|           strategy={horizontalListSortingStrategy} | ||||
|         > | ||||
|           <ul className="sortable-list-horizontal"> | ||||
|             {agrupaciones.map(agrupacion => ( | ||||
|               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||
|               </SortableItem> | ||||
|             ))} | ||||
|           </ul> | ||||
|         </SortableContext> | ||||
|       </DndContext> | ||||
|       <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,102 @@ | ||||
| // src/components/OrdenDiputadosNacionalesManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; | ||||
| import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable'; | ||||
| import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import { SortableItem } from './SortableItem'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const OrdenDiputadosNacionalesManager = () => { | ||||
|   // Estado para la lista que el usuario puede ordenar | ||||
|   const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]); | ||||
|  | ||||
|   // Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.) | ||||
|   const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|     queryKey: ['agrupaciones'], | ||||
|     queryFn: getAgrupaciones, | ||||
|   }); | ||||
|  | ||||
|   // Query 2: Obtener los datos de composición para saber qué partidos tienen bancas | ||||
|   const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({ | ||||
|     queryKey: ['composicionNacional', ELECCION_ID_NACIONAL], | ||||
|     queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL), | ||||
|   }); | ||||
|  | ||||
|   // Este efecto se ejecuta cuando los datos de las queries estén disponibles | ||||
|   useEffect(() => { | ||||
|     // No hacemos nada hasta que ambas queries hayan cargado sus datos | ||||
|     if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Creamos un Set con los IDs de los partidos que tienen al menos una banca de diputado | ||||
|     const partidosConBancasIds = new Set( | ||||
|       composicionData.diputados.partidos | ||||
|         .filter(p => p.bancasTotales > 0) | ||||
|         .map(p => p.id) | ||||
|     ); | ||||
|  | ||||
|     // Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes | ||||
|     const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id)); | ||||
|  | ||||
|     // Ordenamos la lista filtrada según el orden guardado en la BD | ||||
|     agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999)); | ||||
|  | ||||
|     // Actualizamos el estado que se renderiza y que el usuario puede ordenar | ||||
|     setAgrupacionesOrdenadas(agrupacionesFiltradas); | ||||
|  | ||||
|   }, [todasAgrupaciones, composicionData]); // Dependencias: se re-ejecuta si los datos cambian | ||||
|  | ||||
|   const sensors = useSensors( | ||||
|     useSensor(PointerSensor), | ||||
|     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|   ); | ||||
|  | ||||
|   const handleDragEnd = (event: DragEndEvent) => { | ||||
|     const { active, over } = event; | ||||
|     if (over && active.id !== over.id) { | ||||
|       setAgrupacionesOrdenadas((items) => { | ||||
|         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|         const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|         return arrayMove(items, oldIndex, newIndex); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSaveOrder = async () => { | ||||
|     const idsOrdenados = agrupacionesOrdenadas.map(a => a.id); | ||||
|     try { | ||||
|       await updateOrden('diputados-nacionales', idsOrdenados); | ||||
|       alert('Orden de Diputados Nacionales guardado con éxito!'); | ||||
|     } catch (error) { | ||||
|       alert('Error al guardar el orden de Diputados Nacionales.'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const isLoading = isLoadingAgrupaciones || isLoadingComposicion; | ||||
|   if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Ordenar Agrupaciones (Diputados Nacionales)</h3> | ||||
|       <p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p> | ||||
|       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|       <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> | ||||
|         <SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}> | ||||
|           <ul className="sortable-list-horizontal"> | ||||
|             {agrupacionesOrdenadas.map(agrupacion => ( | ||||
|               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||
|               </SortableItem> | ||||
|             ))} | ||||
|           </ul> | ||||
|         </SortableContext> | ||||
|       </DndContext> | ||||
|       <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos | ||||
| const updateOrdenSenadoresApi = async (ids: string[]) => { | ||||
|   const token = localStorage.getItem('admin-jwt-token'); | ||||
|   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-senadores', { | ||||
|       method: 'PUT', | ||||
|       headers: { | ||||
|           'Content-Type': 'application/json', | ||||
|           'Authorization': `Bearer ${token}` | ||||
|       }, | ||||
|       body: JSON.stringify(ids) | ||||
|     method: 'PUT', | ||||
|     headers: { | ||||
|       'Content-Type': 'application/json', | ||||
|       'Authorization': `Bearer ${token}` | ||||
|     }, | ||||
|     body: JSON.stringify(ids) | ||||
|   }); | ||||
|   if (!response.ok) { | ||||
|     throw new Error("Failed to save Senadores order"); | ||||
| @@ -38,77 +38,77 @@ const updateOrdenSenadoresApi = async (ids: string[]) => { | ||||
| }; | ||||
|  | ||||
| export const OrdenSenadoresManager = () => { | ||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const sensors = useSensors( | ||||
|       useSensor(PointerSensor), | ||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|     ); | ||||
|   const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const sensors = useSensors( | ||||
|     useSensor(PointerSensor), | ||||
|     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|   ); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchAndSortAgrupaciones = async () => { | ||||
|             setLoading(true); | ||||
|             try { | ||||
|                 const data = await getAgrupaciones(); | ||||
|                 // Ordenar por el orden de Senadores. Los nulos van al final. | ||||
|                 data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999)); | ||||
|                 setAgrupaciones(data); | ||||
|             } catch (error) { | ||||
|                 console.error("Failed to fetch agrupaciones for Senadores:", error); | ||||
|             } finally { | ||||
|                 setLoading(false); | ||||
|             } | ||||
|         }; | ||||
|         fetchAndSortAgrupaciones(); | ||||
|     }, []); | ||||
|  | ||||
|     const handleDragEnd = (event: DragEndEvent) => { | ||||
|       const { active, over } = event; | ||||
|       if (over && active.id !== over.id) { | ||||
|         setAgrupaciones((items) => { | ||||
|           const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|           const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|           return arrayMove(items, oldIndex, newIndex); | ||||
|         }); | ||||
|   useEffect(() => { | ||||
|     const fetchAndSortAgrupaciones = async () => { | ||||
|       setLoading(true); | ||||
|       try { | ||||
|         const data = await getAgrupaciones(); | ||||
|         // Ordenar por el orden de Senadores. Los nulos van al final. | ||||
|         data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999)); | ||||
|         setAgrupaciones(data); | ||||
|       } catch (error) { | ||||
|         console.error("Failed to fetch agrupaciones for Senadores:", error); | ||||
|       } finally { | ||||
|         setLoading(false); | ||||
|       } | ||||
|     }; | ||||
|     fetchAndSortAgrupaciones(); | ||||
|   }, []); | ||||
|  | ||||
|     const handleSaveOrder = async () => { | ||||
|         const idsOrdenados = agrupaciones.map(a => a.id); | ||||
|         try { | ||||
|             await updateOrdenSenadoresApi(idsOrdenados); | ||||
|             alert('Orden de Senadores guardado con éxito!'); | ||||
|         } catch (error) { | ||||
|             alert('Error al guardar el orden de Senadores.'); | ||||
|         } | ||||
|     }; | ||||
|   const handleDragEnd = (event: DragEndEvent) => { | ||||
|     const { active, over } = event; | ||||
|     if (over && active.id !== over.id) { | ||||
|       setAgrupaciones((items) => { | ||||
|         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|         const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|         return arrayMove(items, oldIndex, newIndex); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|     if (loading) return <p>Cargando orden de Senadores...</p>; | ||||
|   const handleSaveOrder = async () => { | ||||
|     const idsOrdenados = agrupaciones.map(a => a.id); | ||||
|     try { | ||||
|       await updateOrdenSenadoresApi(idsOrdenados); | ||||
|       alert('Orden de Senadores guardado con éxito!'); | ||||
|     } catch (error) { | ||||
|       alert('Error al guardar el orden de Senadores.'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Ordenar Agrupaciones (Senado)</h3> | ||||
|             <p>Arrastre para reordenar.</p> | ||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|             <DndContext | ||||
|               sensors={sensors} | ||||
|               collisionDetection={closestCenter} | ||||
|               onDragEnd={handleDragEnd} | ||||
|             > | ||||
|               <SortableContext | ||||
|                 items={agrupaciones.map(a => a.id)} | ||||
|                 strategy={horizontalListSortingStrategy} | ||||
|               > | ||||
|                 <ul className="sortable-list-horizontal"> | ||||
|                   {agrupaciones.map(agrupacion => ( | ||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button> | ||||
|         </div> | ||||
|     ); | ||||
|   if (loading) return <p>Cargando orden de Senadores...</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Ordenar Agrupaciones (Senado)</h3> | ||||
|       <p>Arrastre para reordenar.</p> | ||||
|       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|       <DndContext | ||||
|         sensors={sensors} | ||||
|         collisionDetection={closestCenter} | ||||
|         onDragEnd={handleDragEnd} | ||||
|       > | ||||
|         <SortableContext | ||||
|           items={agrupaciones.map(a => a.id)} | ||||
|           strategy={horizontalListSortingStrategy} | ||||
|         > | ||||
|           <ul className="sortable-list-horizontal"> | ||||
|             {agrupaciones.map(agrupacion => ( | ||||
|               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||
|               </SortableItem> | ||||
|             ))} | ||||
|           </ul> | ||||
|         </SortableContext> | ||||
|       </DndContext> | ||||
|       <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,94 @@ | ||||
| // src/components/OrdenSenadoresNacionalesManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; | ||||
| import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable'; | ||||
| import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import { SortableItem } from './SortableItem'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const OrdenSenadoresNacionalesManager = () => { | ||||
|   const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]); | ||||
|  | ||||
|   const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|     queryKey: ['agrupaciones'], | ||||
|     queryFn: getAgrupaciones, | ||||
|   }); | ||||
|  | ||||
|   const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({ | ||||
|     queryKey: ['composicionNacional', ELECCION_ID_NACIONAL], | ||||
|     queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL), | ||||
|   }); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Creamos un Set con los IDs de los partidos que tienen al menos una banca de senador | ||||
|     const partidosConBancasIds = new Set( | ||||
|       composicionData.senadores.partidos | ||||
|         .filter(p => p.bancasTotales > 0) | ||||
|         .map(p => p.id) | ||||
|     ); | ||||
|  | ||||
|     const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id)); | ||||
|  | ||||
|     agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999)); | ||||
|  | ||||
|     setAgrupacionesOrdenadas(agrupacionesFiltradas); | ||||
|  | ||||
|   }, [todasAgrupaciones, composicionData]); | ||||
|  | ||||
|   const sensors = useSensors( | ||||
|     useSensor(PointerSensor), | ||||
|     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|   ); | ||||
|  | ||||
|   const handleDragEnd = (event: DragEndEvent) => { | ||||
|     const { active, over } = event; | ||||
|     if (over && active.id !== over.id) { | ||||
|       setAgrupacionesOrdenadas((items) => { | ||||
|         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|         const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|         return arrayMove(items, oldIndex, newIndex); | ||||
|       }); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSaveOrder = async () => { | ||||
|     const idsOrdenados = agrupacionesOrdenadas.map(a => a.id); | ||||
|     try { | ||||
|       await updateOrden('senadores-nacionales', idsOrdenados); | ||||
|       alert('Orden de Senadores Nacionales guardado con éxito!'); | ||||
|     } catch (error) { | ||||
|       alert('Error al guardar el orden de Senadores Nacionales.'); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const isLoading = isLoadingAgrupaciones || isLoadingComposicion; | ||||
|   if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Ordenar Agrupaciones (Senado de la Nación)</h3> | ||||
|       <p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p> | ||||
|       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|       <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> | ||||
|         <SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}> | ||||
|           <ul className="sortable-list-horizontal"> | ||||
|             {agrupacionesOrdenadas.map(agrupacion => ( | ||||
|               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||
|               </SortableItem> | ||||
|             ))} | ||||
|           </ul> | ||||
|         </SortableContext> | ||||
|       </DndContext> | ||||
|       <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										140
									
								
								Elecciones-Web/frontend-admin/src/components/WorkerManager.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,140 @@ | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getConfiguracion, updateConfiguracion, updateLoggingLevel } from '../services/apiService'; | ||||
| import type { ConfiguracionResponse } from '../services/apiService'; | ||||
|  | ||||
| // --- Componente de Switch reutilizable para la UI --- | ||||
| const Switch = ({ label, isChecked, onChange }: { label: string, isChecked: boolean, onChange: (checked: boolean) => void }) => ( | ||||
|     <label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}> | ||||
|         <input type="checkbox" checked={isChecked} onChange={e => onChange(e.target.checked)} /> | ||||
|         {label} | ||||
|     </label> | ||||
| ); | ||||
|  | ||||
| export const WorkerManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     // Estados locales para manejar los valores de la UI | ||||
|     const [resultadosActivado, setResultadosActivado] = useState(true); | ||||
|     const [bajasActivado, setBajasActivado] = useState(true); | ||||
|     const [prioridad, setPrioridad] = useState('Resultados'); | ||||
|     const [loggingLevel, setLoggingLevel] = useState('Information'); | ||||
|      | ||||
|     // Query para obtener la configuración actual desde la API | ||||
|     const { data: configData, isLoading } = useQuery<ConfiguracionResponse>({ | ||||
|         queryKey: ['configuracion'], | ||||
|         queryFn: getConfiguracion, | ||||
|     }); | ||||
|  | ||||
|     // useEffect para sincronizar el estado local con los datos de la API una vez cargados | ||||
|     useEffect(() => { | ||||
|         if (configData) { | ||||
|             setResultadosActivado(configData.Worker_Resultados_Activado === 'true'); | ||||
|             setBajasActivado(configData.Worker_Bajas_Activado === 'true'); | ||||
|             setPrioridad(configData.Worker_Prioridad || 'Resultados'); | ||||
|             setLoggingLevel(configData.Logging_Level || 'Information'); | ||||
|         } | ||||
|     }, [configData]); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         try { | ||||
|             // Creamos dos promesas separadas, una para la config general y otra para el logging | ||||
|             const configPromise = updateConfiguracion({ | ||||
|                 ...configData, | ||||
|                 'Worker_Resultados_Activado': resultadosActivado.toString(), | ||||
|                 'Worker_Bajas_Activado': bajasActivado.toString(), | ||||
|                 'Worker_Prioridad': prioridad, | ||||
|                 'Logging_Level': loggingLevel, | ||||
|             }); | ||||
|              | ||||
|             // La llamada al endpoint de logging-level es la que cambia el nivel EN VIVO. | ||||
|             const loggingPromise = updateLoggingLevel({ level: loggingLevel }); | ||||
|  | ||||
|             // Ejecutamos ambas en paralelo | ||||
|             await Promise.all([configPromise, loggingPromise]); | ||||
|              | ||||
|             queryClient.invalidateQueries({ queryKey: ['configuracion'] }); | ||||
|             alert('Configuración de Workers y Logging guardada.'); | ||||
|  | ||||
|         } catch (error) { | ||||
|             console.error("Error al guardar la configuración:", error); | ||||
|             alert('Error al guardar la configuración.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const isPrioridadDisabled = !resultadosActivado || !bajasActivado; | ||||
|  | ||||
|     if (isLoading) { | ||||
|         return <div className="admin-module"><h3>Gestión de Workers</h3><p>Cargando configuración...</p></div>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Gestión de Workers</h3> | ||||
|             <p>Controla el comportamiento de los procesos de captura de datos.</p> | ||||
|             <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}> | ||||
|                  | ||||
|                 {/* --- Switches On/Off --- */} | ||||
|                 <div style={{ display: 'flex', alignSelf: 'center', gap: '2rem' }}> | ||||
|                     <Switch  | ||||
|                         label="Activar Worker de Resultados" | ||||
|                         isChecked={resultadosActivado} | ||||
|                         onChange={setResultadosActivado} | ||||
|                     /> | ||||
|                     <Switch  | ||||
|                         label="Activar Worker de Bancas/Telegramas" | ||||
|                         isChecked={bajasActivado} | ||||
|                         onChange={setBajasActivado} | ||||
|                     /> | ||||
|                 </div> | ||||
|  | ||||
|                  {/* --- Contenedor para Selectores --- */} | ||||
|                 <div style={{ display: 'flex', gap: '2rem', alignSelf:'center', alignItems: 'flex-start' }}> | ||||
|                     {/* --- Selector de Prioridad --- */} | ||||
|                     <div> | ||||
|                         <label htmlFor="prioridad-select" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}> | ||||
|                             Prioridad (si ambos están activos) | ||||
|                         </label> | ||||
|                         <select  | ||||
|                             id="prioridad-select" | ||||
|                             value={prioridad}  | ||||
|                             onChange={e => setPrioridad(e.target.value)} | ||||
|                             disabled={isPrioridadDisabled} | ||||
|                             style={{ padding: '0.5rem', minWidth: '200px' }} | ||||
|                         > | ||||
|                             <option value="Resultados">Resultados (Noche Electoral)</option> | ||||
|                             <option value="Telegramas">Telegramas (Post-Escrutinio)</option> | ||||
|                         </select> | ||||
|                         {isPrioridadDisabled && <small style={{ display: 'block', marginTop: '0.5rem', color: '#666' }}>Activar ambos workers para elegir prioridad.</small>} | ||||
|                     </div> | ||||
|  | ||||
|                     {/* --- NUEVO: Selector de Nivel de Logging --- */} | ||||
|                     <div> | ||||
|                         <label htmlFor="logging-select" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}> | ||||
|                             Nivel de Logging (En vivo) | ||||
|                         </label> | ||||
|                         <select | ||||
|                             id="logging-select" | ||||
|                             value={loggingLevel} | ||||
|                             onChange={e => setLoggingLevel(e.target.value)} | ||||
|                             style={{ padding: '0.5rem', minWidth: '200px' }} | ||||
|                         > | ||||
|                             <option value="Verbose">Verbose (Máximo detalle)</option> | ||||
|                             <option value="Debug">Debug</option> | ||||
|                             <option value="Information">Information (Normal)</option> | ||||
|                             <option value="Warning">Warning (Advertencias)</option> | ||||
|                             <option value="Error">Error</option> | ||||
|                             <option value="Fatal">Fatal (Críticos)</option> | ||||
|                         </select> | ||||
|                         <small style={{ display: 'block', marginTop: '0.5rem', color: '#666' }}>Cambia el nivel de log en tiempo real.</small> | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                 {/* --- Botón de Guardar --- */} | ||||
|                 <div style={{ marginTop: '1rem' }}> | ||||
|                     <button onClick={handleSave}>Guardar Toda la Configuración</button> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										23
									
								
								Elecciones-Web/frontend-admin/src/constants/categorias.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| // src/constants/categorias.ts | ||||
|  | ||||
| // Opciones para los selectores en el panel de administración | ||||
| export const CATEGORIAS_ADMIN_OPTIONS = [ | ||||
|     // Nacionales | ||||
|     { value: 2, label: 'Senadores Nacionales' }, | ||||
|     { value: 3, label: 'Diputados Nacionales' }, | ||||
|     // Provinciales | ||||
|     { value: 5, label: 'Senadores Provinciales' }, | ||||
|     { value: 6, label: 'Diputados Provinciales' }, | ||||
|     { value: 7, label: 'Concejales' }, | ||||
| ]; | ||||
|  | ||||
| export const CATEGORIAS_NACIONALES_OPTIONS = [ | ||||
|     { value: 2, label: 'Senadores Nacionales' }, | ||||
|     { value: 3, label: 'Diputados Nacionales' }, | ||||
| ]; | ||||
|  | ||||
| export const CATEGORIAS_PROVINCIALES_OPTIONS = [ | ||||
|     { value: 5, label: 'Senadores Provinciales' }, | ||||
|     { value: 6, label: 'Diputados Provinciales' }, | ||||
|     { value: 7, label: 'Concejales' }, | ||||
| ]; | ||||
| @@ -1,18 +1,42 @@ | ||||
| // src/services/apiService.ts | ||||
| import axios from 'axios'; | ||||
| import { triggerLogout } from '../context/authUtils'; | ||||
| import type { AgrupacionPolitica, UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, MunicipioSimple } from '../types'; | ||||
| import type { | ||||
|   CandidatoOverride, AgrupacionPolitica, | ||||
|   UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, | ||||
|   MunicipioSimple, BancaPrevia, ProvinciaSimple | ||||
| } from '../types'; | ||||
|  | ||||
| const AUTH_API_URL = 'http://localhost:5217/api/auth'; | ||||
| const ADMIN_API_URL = 'http://localhost:5217/api/admin'; | ||||
| /** | ||||
|  * URL base para las llamadas a la API. | ||||
|  */ | ||||
| const API_URL_BASE = import.meta.env.DEV | ||||
|   ? 'http://localhost:5217/api' | ||||
|   : 'https://elecciones2025.eldia.com/api'; | ||||
|  | ||||
| /** | ||||
|  * URL completa para el endpoint de autenticación. | ||||
|  */ | ||||
| export const AUTH_API_URL = `${API_URL_BASE}/auth`; | ||||
|  | ||||
| /** | ||||
|  * URL completa para los endpoints de administración. | ||||
|  */ | ||||
| export const ADMIN_API_URL = `${API_URL_BASE}/admin`; | ||||
|  | ||||
| // Cliente de API para endpoints de administración (requiere token) | ||||
| const adminApiClient = axios.create({ | ||||
|   baseURL: ADMIN_API_URL, | ||||
| }); | ||||
|  | ||||
| // --- INTERCEPTORES --- | ||||
| // Cliente de API para endpoints públicos (no envía token) | ||||
| const apiClient = axios.create({ | ||||
|   baseURL: API_URL_BASE, | ||||
|   headers: { 'Content-Type': 'application/json' }, | ||||
| }); | ||||
|  | ||||
| // Interceptor de Peticiones: Añade el token JWT a cada llamada | ||||
|  | ||||
| // --- INTERCEPTORES (Solo para el cliente de admin) --- | ||||
| adminApiClient.interceptors.request.use( | ||||
|   (config) => { | ||||
|     const token = localStorage.getItem('admin-jwt-token'); | ||||
| @@ -24,7 +48,6 @@ adminApiClient.interceptors.request.use( | ||||
|   (error) => Promise.reject(error) | ||||
| ); | ||||
|  | ||||
| // Interceptor de Respuestas: Maneja la expiración del token (error 401) | ||||
| adminApiClient.interceptors.response.use( | ||||
|   (response) => response, | ||||
|   (error) => { | ||||
| @@ -36,6 +59,32 @@ adminApiClient.interceptors.response.use( | ||||
|   } | ||||
| ); | ||||
|  | ||||
|  | ||||
| // --- INTERFACES PARA COMPOSICIÓN NACIONAL (NECESARIAS PARA EL NUEVO MÉTODO) --- | ||||
| export interface PartidoComposicionNacional { | ||||
|   id: string; | ||||
|   nombre: string; | ||||
|   nombreCorto: string | null; | ||||
|   color: string | null; | ||||
|   bancasFijos: number; | ||||
|   bancasGanadas: number; | ||||
|   bancasTotales: number; | ||||
|   ordenDiputadosNacionales: number | null; | ||||
|   ordenSenadoresNacionales: number | null; | ||||
| } | ||||
| export interface CamaraComposicionNacional { | ||||
|   camaraNombre: string; | ||||
|   totalBancas: number; | ||||
|   bancasEnJuego: number; | ||||
|   partidos: PartidoComposicionNacional[]; | ||||
|   presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null; | ||||
| } | ||||
| export interface ComposicionNacionalData { | ||||
|   diputados: CamaraComposicionNacional; | ||||
|   senadores: CamaraComposicionNacional; | ||||
| } | ||||
|  | ||||
|  | ||||
| // --- SERVICIOS DE API --- | ||||
|  | ||||
| // 1. Autenticación | ||||
| @@ -51,7 +100,7 @@ export const loginUser = async (credentials: LoginCredentials): Promise<string | | ||||
|   } | ||||
| }; | ||||
|  | ||||
| // 2. Agrupaciones Políticas | ||||
| // 2. Agrupaciones | ||||
| export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => { | ||||
|   const response = await adminApiClient.get('/agrupaciones'); | ||||
|   return response.data; | ||||
| @@ -62,14 +111,14 @@ export const updateAgrupacion = async (id: string, data: UpdateAgrupacionData): | ||||
| }; | ||||
|  | ||||
| // 3. Ordenamiento de Agrupaciones | ||||
| export const updateOrden = async (camara: 'diputados' | 'senadores', ids: string[]): Promise<void> => { | ||||
| export const updateOrden = async (camara: 'diputados' | 'senadores' | 'diputados-nacionales' | 'senadores-nacionales', ids: string[]): Promise<void> => { | ||||
|   await adminApiClient.put(`/agrupaciones/orden-${camara}`, ids); | ||||
| }; | ||||
|  | ||||
| // 4. Gestión de Bancas y Ocupantes | ||||
| export const getBancadas = async (camara: 'diputados' | 'senadores'): Promise<Bancada[]> => { | ||||
|   const camaraId = camara === 'diputados' ? 0 : 1; | ||||
|   const response = await adminApiClient.get(`/bancadas/${camaraId}`); | ||||
| // 4. Gestión de Bancas | ||||
| export const getBancadas = async (camara: 'diputados' | 'senadores', eleccionId: number): Promise<Bancada[]> => { | ||||
|   const camaraId = (camara === 'diputados') ? 0 : 1; | ||||
|   const response = await adminApiClient.get(`/bancadas/${camaraId}?eleccionId=${eleccionId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -96,18 +145,64 @@ export const updateConfiguracion = async (data: Record<string, string>): Promise | ||||
|   await adminApiClient.put('/configuracion', data); | ||||
| }; | ||||
|  | ||||
| export const getLogos = async (): Promise<LogoAgrupacionCategoria[]> => { | ||||
|   const response = await adminApiClient.get('/logos'); | ||||
| // 6. Logos y Candidatos | ||||
| export const getLogos = async (eleccionId: number): Promise<LogoAgrupacionCategoria[]> => { | ||||
|   const response = await adminApiClient.get(`/logos?eleccionId=${eleccionId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const updateLogos = async (data: LogoAgrupacionCategoria[]): Promise<void> => { | ||||
|   await adminApiClient.put('/logos', data); | ||||
| }; | ||||
| export const getCandidatos = async (eleccionId: number): Promise<CandidatoOverride[]> => { | ||||
|   const response = await adminApiClient.get(`/candidatos?eleccionId=${eleccionId}`); | ||||
|   return response.data; | ||||
| }; | ||||
| export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => { | ||||
|   await adminApiClient.put('/candidatos', data); | ||||
| }; | ||||
|  | ||||
| // 7. Catálogos | ||||
| export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => { | ||||
|     // Ahora usa adminApiClient, que apunta a /api/admin/ | ||||
|     // La URL final será /api/admin/catalogos/municipios | ||||
|     const response = await adminApiClient.get('/catalogos/municipios'); | ||||
|     return response.data; | ||||
|   const response = await adminApiClient.get('/catalogos/municipios'); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| // 8. Logging | ||||
| export interface UpdateLoggingLevelData { level: string; } | ||||
| export const updateLoggingLevel = async (data: UpdateLoggingLevelData): Promise<void> => { | ||||
|   await adminApiClient.put(`/logging-level`, data); | ||||
| }; | ||||
|  | ||||
| // 9. Bancas Previas | ||||
| export const getBancasPrevias = async (eleccionId: number): Promise<BancaPrevia[]> => { | ||||
|   const response = await adminApiClient.get(`/bancas-previas/${eleccionId}`); | ||||
|   return response.data; | ||||
| }; | ||||
| export const updateBancasPrevias = async (eleccionId: number, data: BancaPrevia[]): Promise<void> => { | ||||
|   await adminApiClient.put(`/bancas-previas/${eleccionId}`, data); | ||||
| }; | ||||
|  | ||||
| // 10. Obtener Composición Nacional (Endpoint Público) | ||||
| export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => { | ||||
|   // Este es un endpoint público, por lo que usamos el cliente sin token de admin. | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| // Obtenemos las provincias para el selector de ámbito | ||||
| export const getProvinciasForAdmin = async (): Promise<ProvinciaSimple[]> => { | ||||
|   const response = await adminApiClient.get('/catalogos/provincias'); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export interface CreateAgrupacionData { | ||||
|   nombre: string; | ||||
|   nombreCorto: string | null; | ||||
|   color: string | null; | ||||
| } | ||||
|  | ||||
| // Servicio para crear una nueva agrupación | ||||
| export const createAgrupacion = async (data: CreateAgrupacionData): Promise<AgrupacionPolitica> => { | ||||
|   const response = await adminApiClient.post('/agrupaciones', data); | ||||
|   return response.data; | ||||
| }; | ||||
| @@ -8,6 +8,8 @@ export interface AgrupacionPolitica { | ||||
|   color: string | null; | ||||
|   ordenDiputados: number | null; | ||||
|   ordenSenadores: number | null; | ||||
|   ordenDiputadosNacionales: number | null; | ||||
|   ordenSenadoresNacionales: number | null; | ||||
| } | ||||
|  | ||||
| export interface UpdateAgrupacionData { | ||||
| @@ -30,9 +32,9 @@ export interface OcupanteBanca { | ||||
|   periodo: string | null; | ||||
| } | ||||
|  | ||||
| // Nueva interfaz para la Bancada | ||||
| export interface Bancada { | ||||
|   id: number; | ||||
|   eleccionId: number; // Clave para diferenciar provinciales de nacionales | ||||
|   camara: TipoCamaraValue; | ||||
|   numeroBanca: number; | ||||
|   agrupacionPoliticaId: string | null; | ||||
| @@ -40,12 +42,35 @@ export interface Bancada { | ||||
|   ocupante: OcupanteBanca | null; | ||||
| } | ||||
|  | ||||
| // Nueva interfaz para Bancas Previas | ||||
| export interface BancaPrevia { | ||||
|     id: number; | ||||
|     eleccionId: number; | ||||
|     camara: TipoCamaraValue; | ||||
|     agrupacionPoliticaId: string; | ||||
|     agrupacionPolitica?: AgrupacionPolitica; // Opcional para la UI | ||||
|     cantidad: number; | ||||
| } | ||||
|  | ||||
|  | ||||
| export interface LogoAgrupacionCategoria { | ||||
|     id: number; | ||||
|     eleccionId: number; // Clave para diferenciar | ||||
|     agrupacionPoliticaId: string; | ||||
|     categoriaId: number; | ||||
|     categoriaId: number | null; | ||||
|     logoUrl: string | null; | ||||
|     ambitoGeograficoId: number | null; | ||||
| } | ||||
|  | ||||
| export interface MunicipioSimple { id: string; nombre: string; } | ||||
| export interface MunicipioSimple { id: string; nombre: string; } | ||||
|  | ||||
| export interface ProvinciaSimple { id: string; nombre: string; } | ||||
|  | ||||
| export interface CandidatoOverride { | ||||
|   id: number; | ||||
|   eleccionId: number; // Clave para diferenciar | ||||
|   agrupacionPoliticaId: string; | ||||
|   categoriaId: number; | ||||
|   ambitoGeograficoId: number | null; | ||||
|   nombreCandidato: string | null; | ||||
| } | ||||
| @@ -1,34 +1,20 @@ | ||||
| # --- Etapa 1: Build (Construcción) --- | ||||
| # Usamos una imagen de Node.js para instalar dependencias y construir la aplicación de React. | ||||
| #Dockerfile | ||||
| # --- Etapa 1: Build --- | ||||
| FROM node:20-alpine AS build | ||||
|  | ||||
| # Establecemos el directorio de trabajo dentro del contenedor. | ||||
| WORKDIR /app | ||||
|  | ||||
| # Copiamos los archivos de manifiesto del proyecto. | ||||
| COPY package.json ./ | ||||
| COPY package-lock.json ./ | ||||
|  | ||||
| # Instalamos las dependencias. | ||||
| COPY package*.json ./ | ||||
| RUN npm install | ||||
|  | ||||
| # Copiamos el resto del código fuente de la aplicación. | ||||
| COPY . . | ||||
|  | ||||
| # Ejecutamos el script de construcción de Vite para generar los archivos estáticos. | ||||
| RUN npm run build | ||||
|  | ||||
| # --- Etapa 2: Serve (Servir) --- | ||||
| # Usamos una imagen de Nginx, que es un servidor web muy ligero y eficiente. | ||||
| # Es ideal para servir archivos estáticos (HTML, CSS, JS). | ||||
| # --- Etapa 2: Producción --- | ||||
| FROM nginx:1.25-alpine | ||||
|  | ||||
| # Copiamos los archivos de producción construidos en la etapa anterior | ||||
| # al directorio por defecto donde Nginx sirve los archivos. | ||||
| COPY --from=build /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # Exponemos el puerto 80 (el puerto por defecto de Nginx). | ||||
| EXPOSE 80 | ||||
| # Copia la configuración de Nginx al contenedor del frontend | ||||
| COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| # El comando por defecto de la imagen de Nginx ya es iniciar el servidor, | ||||
| # así que no necesitamos un CMD o ENTRYPOINT. | ||||
| EXPOSE 80 | ||||
| # El CMD es opcional ya que la imagen base lo tiene, pero no hace daño | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
| @@ -1,3 +1,4 @@ | ||||
| //eslint.config.js | ||||
| import js from '@eslint/js' | ||||
| import globals from 'globals' | ||||
| import reactHooks from 'eslint-plugin-react-hooks' | ||||
|   | ||||
							
								
								
									
										30
									
								
								Elecciones-Web/frontend/frontend.nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,30 @@ | ||||
| server { | ||||
|     listen 80; | ||||
|     server_name localhost; | ||||
|     root /usr/share/nginx/html; | ||||
|     index index.html; | ||||
|  | ||||
|     # --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) --- | ||||
|     location = /bootstrap.js { | ||||
|         # 1. Aseguramos que Nginx genere la huella digital ETag. | ||||
|         etag on; | ||||
|  | ||||
|         # 2. Instrucciones explícitas de no cachear. | ||||
|         expires -1; # Equivalente a 'off', pero a veces más fuerte. | ||||
|         add_header Cache-Control "no-cache, must-revalidate, private"; | ||||
|          | ||||
|         try_files $uri =404; | ||||
|     } | ||||
|  | ||||
|     # Bloque para activos con hash (sin cambios, ya es correcto) | ||||
|     location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { | ||||
|         expires 1y; | ||||
|         add_header Cache-Control "public"; | ||||
|         try_files $uri =404; | ||||
|     } | ||||
|  | ||||
|     # Bloque para la SPA (sin cambios) | ||||
|     location / { | ||||
|         try_files $uri $uri/ /index.html; | ||||
|     } | ||||
| } | ||||
| @@ -2,12 +2,12 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/eldia.svg" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Vite + React + TS</title> | ||||
|     <title>Elecciones 2025 - Dev Showcase</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|   </body> | ||||
| </html> | ||||
| </html> | ||||
							
								
								
									
										624
									
								
								Elecciones-Web/frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -11,18 +11,27 @@ | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@nivo/bar": "^0.99.0", | ||||
|     "@nivo/waffle": "^0.99.0", | ||||
|     "@tanstack/react-query": "^5.85.5", | ||||
|     "@types/d3-geo": "^3.1.0", | ||||
|     "@types/d3-shape": "^3.1.7", | ||||
|     "axios": "^1.11.0", | ||||
|     "d3-geo": "^3.1.1", | ||||
|     "d3-shape": "^3.2.0", | ||||
|     "highcharts": "^12.4.0", | ||||
|     "highcharts-react-official": "^3.2.2", | ||||
|     "react": "^19.1.1", | ||||
|     "react-circular-progressbar": "^2.2.0", | ||||
|     "react-dom": "^19.1.1", | ||||
|     "react-hot-toast": "^2.6.0", | ||||
|     "react-icons": "^5.5.0", | ||||
|     "react-pdf": "^10.1.0", | ||||
|     "react-select": "^5.10.2", | ||||
|     "react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support", | ||||
|     "react-tooltip": "^5.29.1" | ||||
|     "react-tooltip": "^5.29.1", | ||||
|     "swiper": "^12.0.2", | ||||
|     "topojson-client": "^3.1.0", | ||||
|     "vite-plugin-svgr": "^4.5.0" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.33.0", | ||||
| @@ -30,6 +39,7 @@ | ||||
|     "@types/react": "^19.1.10", | ||||
|     "@types/react-dom": "^19.1.7", | ||||
|     "@types/react-select": "^5.0.0", | ||||
|     "@types/topojson-client": "^3.1.5", | ||||
|     "@vitejs/plugin-react": "^5.0.0", | ||||
|     "eslint": "^9.33.0", | ||||
|     "eslint-plugin-react-hooks": "^5.2.0", | ||||
|   | ||||
							
								
								
									
										138
									
								
								Elecciones-Web/frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,138 @@ | ||||
| // frontend/public/bootstrap.js | ||||
|  | ||||
| (function () { | ||||
|   // El dominio donde se alojan los widgets | ||||
|   const WIDGETS_HOST = 'https://elecciones2025.eldia.com'; | ||||
|  | ||||
|   // Estado interno para evitar recargas y re-fetch innecesarios | ||||
|   const __state = { | ||||
|     assetsLoaded: false, | ||||
|     manifest: null, | ||||
|   }; | ||||
|  | ||||
|   // Función para cargar dinámicamente un script (evita duplicados) | ||||
|   function loadScript(src) { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       if ([...document.scripts].some(s => s.src === src)) return resolve(); | ||||
|       const script = document.createElement('script'); | ||||
|       script.type = 'module'; | ||||
|       script.src = src; | ||||
|       script.onload = resolve; | ||||
|       script.onerror = reject; | ||||
|       document.head.appendChild(script); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Función para cargar dinámicamente una hoja de estilos (evita duplicados) | ||||
|   function loadCSS(href) { | ||||
|     if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(l => l.href === href)) return; | ||||
|     const link = document.createElement('link'); | ||||
|     link.rel = 'stylesheet'; | ||||
|     link.href = href; | ||||
|     document.head.appendChild(link); | ||||
|   } | ||||
|  | ||||
|   // Carga (una sola vez) JS/CSS definidos por el manifest | ||||
|   async function ensureAssetsFromManifest() { | ||||
|     if (__state.assetsLoaded) return; | ||||
|  | ||||
|     // 1) Obtener el manifest.json (cache: no-store por si hay deploys frecuentes) | ||||
|     if (!__state.manifest) { | ||||
|       const response = await fetch(`${WIDGETS_HOST}/manifest.json`, { cache: 'no-store' }); | ||||
|       if (!response.ok) throw new Error('No se pudo cargar el manifest de los widgets.'); | ||||
|       __state.manifest = await response.json(); | ||||
|     } | ||||
|  | ||||
|     // 2) Encontrar el entry principal (isEntry=true) | ||||
|     const entryKey = Object.keys(__state.manifest).find(key => __state.manifest[key].isEntry); | ||||
|     if (!entryKey) throw new Error('No se encontró el punto de entrada en el manifest.'); | ||||
|  | ||||
|     const entry = __state.manifest[entryKey]; | ||||
|     const jsUrl = `${WIDGETS_HOST}/${entry.file}`; | ||||
|  | ||||
|     // 3) Cargar el CSS si existe (una sola vez) | ||||
|     if (entry.css && entry.css.length > 0) { | ||||
|       entry.css.forEach(cssFile => loadCSS(`${WIDGETS_HOST}/${cssFile}`)); | ||||
|     } | ||||
|  | ||||
|     // 4) Cargar el JS principal (una sola vez) | ||||
|     await loadScript(jsUrl); | ||||
|  | ||||
|     __state.assetsLoaded = true; | ||||
|   } | ||||
|  | ||||
|   // Render: busca contenedores y llama a la API global del widget | ||||
|   function renderWidgetsOnPage() { | ||||
|     if (!(window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function')) { | ||||
|       // La librería aún no expuso la API (puede ocurrir en primeros ms tras cargar) | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const widgetContainers = document.querySelectorAll('[data-elecciones-widget]'); | ||||
|     if (widgetContainers.length === 0) { | ||||
|       // En algunas rutas no habrá widgets: no es error. | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     widgetContainers.forEach(container => { | ||||
|       window.EleccionesWidgets.render(container, container.dataset); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Función principal (re-usable) para inicializar y renderizar | ||||
|   async function initWidgets() { | ||||
|     try { | ||||
|       await ensureAssetsFromManifest(); | ||||
|       renderWidgetsOnPage(); | ||||
|     } catch (error) { | ||||
|       console.error('Error al inicializar los widgets de elecciones:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Exponer para invocación manual (por ejemplo, en hooks del router) | ||||
|   window.__eleccionesInit = initWidgets; | ||||
|  | ||||
|   // Primer render en carga inicial | ||||
|   if (document.readyState === 'loading') { | ||||
|     document.addEventListener('DOMContentLoaded', initWidgets); | ||||
|   } else { | ||||
|     initWidgets(); | ||||
|   } | ||||
|  | ||||
|   // --- Reinvocar en cada navegación de SPA --- | ||||
|   function dispatchLocationChange() { | ||||
|     window.dispatchEvent(new Event('locationchange')); | ||||
|   } | ||||
|  | ||||
|   ['pushState', 'replaceState'].forEach(method => { | ||||
|     const orig = history[method]; | ||||
|     history[method] = function () { | ||||
|       const ret = orig.apply(this, arguments); | ||||
|       dispatchLocationChange(); | ||||
|       return ret; | ||||
|     }; | ||||
|   }); | ||||
|   window.addEventListener('popstate', dispatchLocationChange); | ||||
|  | ||||
|   let navDebounce = null; | ||||
|   window.addEventListener('locationchange', () => { | ||||
|     clearTimeout(navDebounce); | ||||
|     navDebounce = setTimeout(() => { | ||||
|       initWidgets(); | ||||
|     }, 0); | ||||
|   }); | ||||
|  | ||||
|   // --- (Opcional) Re-render si aparecen contenedores luego del montaje de la vista --- | ||||
|   const mo = new MutationObserver((mutations) => { | ||||
|     for (const m of mutations) { | ||||
|       if (m.type === 'childList') { | ||||
|         const added = [...m.addedNodes].some(n => | ||||
|           n.nodeType === 1 && | ||||
|           (n.matches?.('[data-elecciones-widget]') || n.querySelector?.('[data-elecciones-widget]')) | ||||
|         ); | ||||
|         if (added) { renderWidgetsOnPage(); break; } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|   mo.observe(document.body, { childList: true, subtree: true }); | ||||
| })(); | ||||
							
								
								
									
										6
									
								
								Elecciones-Web/frontend/public/eldia.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,6 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69"> | ||||
| <path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/> | ||||
| <path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/> | ||||
| <path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
| After Width: | Height: | Size: 9.8 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/caba.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="1.3493201mm" | ||||
|    height="1.6933239mm" | ||||
|    viewBox="0 0 1.3493201 1.6933238" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-103.9813,-147.63758)"> | ||||
|     <path | ||||
|        d="m 105.33062,148.53708 -0.0264,0.0794 -0.1323,0.13229 -0.1852,0.0265 -0.15875,0.15875 -0.21167,0.39687 -0.52917,-0.44979 -0.10583,-0.37042 0.13229,-0.58208 0.34396,-0.29104 0.13229,0.0794 0.10583,0.0529 0.10584,0.0794 0.18521,0.13229 0.0794,0.0794 0.10583,0.15875 0.0794,0.15875 z" | ||||
|        id="ARC" | ||||
|        name="Ciudad de Buenos Aires" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 821 B | 
| After Width: | Height: | Size: 7.7 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/chaco.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.0 KiB | 
| After Width: | Height: | Size: 12 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="22.092972mm" | ||||
|    height="36.143562mm" | ||||
|    viewBox="0 0 22.092972 36.143562" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-93.662753,-130.4396)"> | ||||
|     <path | ||||
|        d="m 95.646872,134.9375 1.190625,-0.3175 0.264583,-0.15875 0.47625,-0.50271 0.582083,-0.66146 0.05292,-0.10583 0.02646,-0.13229 -0.15875,-1.45521 0.05292,-0.21167 0.370417,-0.13229 1.5875,-0.39687 1.03188,-0.29105 0.60854,-0.13229 h 0.21167 l 1.08479,0.26459 0.0529,0.0264 v 0.0265 l 0.0265,0.0794 -0.0265,0.15875 v 0.0794 l 0.0265,0.0529 0.0794,0.0529 0.39687,0.23812 0.0265,0.0265 0.0529,0.0794 0.0529,0.0529 0.0265,0.0265 0.34396,0.0529 0.0529,0.0265 0.0265,0.0265 0.0794,0.0529 0.0529,0.0529 0.0529,0.0265 h 0.0529 l 0.3175,0.0265 h 0.10583 l 0.635,-0.10583 0.0794,0.0265 0.39687,0.10583 0.0529,0.0265 0.21167,-0.0529 h 0.0529 l 0.0265,0.0265 0.0265,0.0265 v 0.0265 0.0265 0.0265 l -0.0265,0.18521 v 0.0265 l 0.0265,0.0529 h 0.0529 l 0.1852,0.0265 0.0529,0.0264 0.0529,0.0265 v 0.0265 0.0529 0.10583 l 0.0265,0.0265 0.0264,0.0529 0.1323,0.0264 h 3.12208 l 1.85208,-0.0264 0.89959,0.0529 0.26458,0.15875 0.84667,2.2225 -0.26459,1.66688 0.0265,0.15875 0.0265,0.10583 1.21708,1.34938 0.26459,0.37041 -0.0529,0.37042 -1.40229,5.26521 -0.10584,0.21166 -0.23812,0.21167 -0.21167,0.21167 -0.0794,0.0794 v 0.0794 0.0265 l 0.0794,0.15875 0.0265,0.0265 v 0.0265 0.0529 0.0794 l -0.0265,0.29105 v 0.0794 l 0.0265,0.39688 0.0265,0.0264 v 0.0265 0.0265 l 0.0794,0.13229 0.0529,0.0529 0.0265,0.0265 v 0.0529 0.0529 l -0.0794,0.18521 -0.0529,0.10583 -0.0265,0.10584 -0.0265,0.15875 v 0.52916 l 0.13229,0.15875 0.13229,0.10584 0.15875,0.21166 0.0529,0.0265 0.15875,0.0794 0.10583,0.0794 0.0265,0.0265 0.0265,0.0529 0.15875,0.55562 0.0265,0.10583 0.13229,0.21167 0.39688,0.29104 0.0529,0.0794 0.0794,0.0794 0.0265,0.0529 0.0264,0.13229 0.0529,0.21167 v 0.21166 l 0.0265,0.0265 0.0265,0.0529 0.0265,0.0265 0.0529,0.0529 0.0265,0.0265 0.0265,0.0529 v 0.0794 l -0.0265,0.0529 v 0.0265 0.0529 l -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.10583 v 0.0265 0.0529 l 0.0265,0.0265 h 0.0529 l 0.15875,0.0265 0.0265,0.0265 H 115.2 l 0.0265,0.0265 v 0.0265 l 0.15875,0.29104 0.10583,0.21167 v 0.0529 l 0.0265,0.0265 0.15875,0.10584 0.0265,0.0265 v 0.0529 l 0.0529,0.0794 v 0.0529 0.0529 0.0529 l -0.0264,0.10583 -0.0794,0.15875 -0.0265,0.13229 v 0.39688 0.15875 l -0.0265,0.13229 -0.0265,0.0265 -0.0794,0.15875 -0.18521,0.21166 -0.0794,0.0794 -0.0794,0.0265 -0.0265,0.0265 h -0.0264 l -0.0794,0.0264 h -0.0529 l -0.0794,-0.0264 -0.13229,0.21166 -1.24354,2.01084 -1.66688,2.7252 -2.2225,3.6248 -2.67229,-0.0265 -0.18521,0.23812 -0.0265,1.08479 -0.0265,2.83105 h -3.12208 -2.91041 -3.28084 v -0.0265 -6.87917 l -0.264584,-4.97417 0.02646,-0.0529 0.02646,-0.0794 0.15875,-0.29104 0.02646,-0.0265 v -0.0265 l 0.02646,-0.0264 h 0.05292 l 0.132292,-0.0265 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0529 v -0.0264 -0.0529 l -0.05292,-0.13229 v -0.10584 l 0.02646,-0.0794 0.132292,-0.29104 0.02646,-0.0794 v -0.0529 l -0.02646,-0.10584 -0.02646,-0.0794 0.02646,-0.0529 v -0.0529 l 0.211666,-0.39687 0.02646,-0.0529 v -0.0529 -0.23813 l 0.02646,-0.0529 v -0.0529 l 0.07937,-0.13229 0.02646,-0.0529 v -0.0265 -0.10583 -0.0794 -0.0265 l 0.02646,-0.0529 0.07938,-0.0794 0.02646,-0.0265 0.02646,-0.0529 0.02646,-0.0529 0.02646,-0.26458 0.132292,-0.29104 0.02646,-0.18521 0.02646,-0.21167 v -0.0529 l -0.02646,-0.0529 -0.02646,-0.13229 -0.02646,-0.10584 -0.02646,-0.13229 -0.132292,-0.23812 -0.02646,-0.0794 v -0.0529 -0.15875 l 0.02646,-0.15875 v -0.1852 -0.18521 l -0.02646,-0.0794 v -0.0794 h -0.02646 l -0.02646,-0.0265 -0.05292,-0.0265 h -0.02646 -0.02646 l -0.105833,0.0265 -0.555625,0.15875 -0.582084,0.0794 -0.07937,-0.0265 -0.02646,-0.0265 -0.02646,-0.0265 v -0.0529 -0.0529 -0.26459 l -0.02646,-0.0794 v -0.0265 l -0.07937,-0.13229 -0.05292,-0.10584 -0.05292,-0.13229 v -0.0794 -0.0794 -0.10583 -0.0265 l -0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.0529 -0.15875,-0.13229 -0.15875,-0.18521 -1.534583,-0.9525 -0.264584,-0.13229 -0.185208,-0.0529 h -0.47625 v -1.69334 l -0.02646,-2.24896 v -1.11125 l 0.05292,-0.39687 0.370417,-1.08479 0.07937,-0.21167 0.105833,-0.3175 0.449792,-1.34937 v -0.10584 l 0.05292,-0.18521 0.132291,-0.44979 0.07937,-0.23812 0.02646,-0.0794 0.105833,-0.18521 0.07937,-0.1852 z" | ||||
|        id="ARX" | ||||
|        name="Córdoba" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 4.5 KiB | 
| After Width: | Height: | Size: 6.1 KiB | 
| After Width: | Height: | Size: 5.6 KiB | 
| After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/jujuy.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.5 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="26.908112mm" | ||||
|    height="29.739168mm" | ||||
|    viewBox="0 0 26.908112 29.739168" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-91.281242,-133.61455)"> | ||||
|     <path | ||||
|        d="m 91.545834,150.99771 v -5.00063 l -0.05292,-3.78354 -0.15875,-0.82021 -0.02646,-0.0794 -0.02646,-0.10584 0.02646,-0.29104 0.05292,-0.44979 0.07937,-0.0794 0.105834,-0.0529 h 6.085416 0.423334 2.434162 3.01625 0.21167 4.89479 0.21167 0.0264 v -1.05833 l 0.0265,-5.66209 h 3.28084 2.91041 3.12208 v 1.82563 1.50812 l -0.0264,3.04271 v 0.23813 2.83104 1.53458 1.53458 2.9898 l 0.0264,3.09562 -0.0264,0.3175 0.0264,3.41313 v 3.88937 l -0.0264,3.51896 -0.47625,-0.0529 -0.21167,-0.0794 -0.15875,-0.15875 -0.71437,-0.50271 -0.18521,-0.0794 -0.10584,-0.13229 -0.13229,-0.23812 -0.0794,-0.0794 -0.0794,-0.0529 -0.21166,-0.1323 -0.37042,-0.0794 -0.10583,-0.0529 -0.47625,-0.58208 -0.0794,-0.0794 -0.26458,-0.0265 -0.10584,-0.0529 -0.39687,-0.26458 -1.77271,-0.68792 h -0.39687 l -1.50813,-0.29104 -0.10583,0.0529 -0.18521,-0.0529 -0.39688,0.0529 -0.13229,-0.10583 h -0.0529 -0.74084 l -0.82021,0.15875 -0.26458,-0.0265 -0.23812,0.10584 h -0.0794 l -0.0794,-0.0265 -0.21167,-0.15875 -0.47625,-0.18521 -0.29104,-0.0529 h -0.15875 l -0.10584,0.0794 -0.0794,0.0794 -0.13229,0.0794 -0.10584,0.0529 h -0.10583 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0529,-0.0529 -0.0794,-0.0265 h -0.23813 -0.0529 l -0.10583,-0.10583 -0.0529,-0.0265 -0.0794,-0.0265 -0.21167,-0.0265 -0.18521,-0.0794 -0.15875,-0.0265 -0.10583,-0.0794 h -0.0529 l -1.40229,-0.18521 -0.74083,0.13229 h -0.23813 l -0.635,-0.18521 h -0.15875 l -0.13229,-0.0529 -0.0529,-0.10584 -0.0529,-0.34396 -0.0529,-0.21166 -0.13229,-0.18521 -0.18521,-0.15875 -0.18521,-0.10583 -0.238117,-0.0529 -0.05292,-0.0265 -0.105833,-0.0794 -0.02646,-0.0265 -0.05292,-0.0265 -0.211667,-0.13229 -0.264583,-0.0265 -0.105834,-0.0529 -0.185208,-0.13229 -0.238125,-0.0529 -0.555625,-0.29104 h -0.05292 l -0.02646,-0.0529 -0.07937,-0.10584 -0.07937,-0.0794 -0.211667,-0.29104 -0.132291,-0.37042 v -0.0794 -0.15875 l -0.02646,-0.10583 -0.05292,-0.10583 -0.132292,-0.0529 h -0.105833 l -0.47625,0.0794 -0.238125,0.15875 -0.15875,0.0529 -0.396875,-0.0529 -0.15875,0.0794 -0.396875,0.0265 -0.211667,-0.10583 -0.185208,-0.18521 -0.15875,-0.21167 -0.291042,-0.635 -0.15875,-0.15875 -0.211667,-0.10583 h -0.05292 l -0.15875,0.0529 h -0.05292 l -0.07937,-0.0794 h -0.05292 l -0.05292,-0.0529 -0.05292,-0.15875 -0.02646,-0.13229 -0.02646,-0.13229 0.02646,-0.34396 0.132291,-0.23812 0.15875,-0.15875 0.370417,-0.26459 0.132292,-0.18521 0.07937,-0.26458 v -0.3175 l -0.07937,-0.21167 -0.132292,-0.23812 -0.15875,-0.21167 -0.15875,-0.13229 -0.238125,-0.13229 z" | ||||
|        id="ARL" | ||||
|        name="La Pampa" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 2.9 KiB | 
| After Width: | Height: | Size: 7.2 KiB | 
| After Width: | Height: | Size: 8.4 KiB | 
| After Width: | Height: | Size: 7.4 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| After Width: | Height: | Size: 7.6 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/salta.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 11 KiB | 
| After Width: | Height: | Size: 8.7 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="13.838294mm" | ||||
|    height="27.438555mm" | ||||
|    viewBox="0 0 13.838294 27.438555" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-97.895573,-134.6725)"> | ||||
|     <path | ||||
|        d="m 101.54708,134.85812 0.37042,0.0794 0.13229,0.0529 0.15875,0.0794 0.0529,0.0265 h 0.0265 0.0529 0.10584 l 0.10583,-0.0529 0.0529,-0.0265 0.0265,0.0265 0.0529,0.0265 0.13229,0.0794 h 0.0265 l 0.29104,-0.0529 h 0.0529 l 0.0529,0.0265 0.0794,0.0265 0.13229,0.0794 h 0.0529 0.0529 l 0.1852,-0.0529 h 0.0794 l 0.26458,0.0265 0.26459,-0.0265 0.29104,-0.10583 0.21166,-0.13229 0.1323,-0.0529 0.26458,-0.0529 h 0.21167 l 0.26458,0.0529 h 0.0529 l 0.0264,0.0265 0.0794,0.0529 0.0529,0.0265 h 0.0264 l 0.10584,0.0265 h 0.26458 l 0.52917,-0.10583 h 0.47625 l 0.18521,0.0529 0.26458,0.13229 1.53458,0.9525 0.15875,0.18521 0.15875,0.13229 0.0265,0.0529 0.0265,0.0529 0.0265,0.10584 v 0.0264 0.10584 0.0794 0.0794 l 0.0529,0.13229 0.0529,0.10583 0.0794,0.13229 v 0.0265 l 0.0264,0.0794 v 0.26458 0.0529 0.0529 l 0.0265,0.0265 0.0265,0.0265 0.0794,0.0265 0.58208,-0.0794 0.55562,-0.15875 0.10584,-0.0265 h 0.0265 0.0264 l 0.0529,0.0265 0.0265,0.0265 h 0.0265 v 0.0794 l 0.0265,0.0794 v 0.18521 0.18521 l -0.0265,0.15875 v 0.15875 0.0529 l 0.0265,0.0794 0.13229,0.23813 0.0265,0.13229 0.0265,0.10583 0.0265,0.13229 0.0265,0.0529 v 0.0529 l -0.0265,0.21166 -0.0265,0.18521 -0.13229,0.29104 -0.0265,0.26459 -0.0265,0.0529 -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.0794 -0.0264,0.0529 v 0.0265 0.0794 0.10584 0.0264 l -0.0265,0.0529 -0.0794,0.13229 v 0.0529 l -0.0265,0.0529 v 0.23812 0.0529 l -0.0264,0.0529 -0.21167,0.39688 v 0.0529 l -0.0265,0.0529 0.0265,0.0794 0.0265,0.10583 v 0.0529 l -0.0265,0.0794 -0.13229,0.29104 -0.0265,0.0794 v 0.10583 l 0.0529,0.13229 v 0.0529 0.0265 l -0.0265,0.0529 -0.0529,0.0265 -0.0265,0.0265 -0.13229,0.0265 h -0.0529 l -0.0265,0.0265 v 0.0265 l -0.0265,0.0265 -0.15875,0.29104 -0.0265,0.0794 -0.0265,0.0529 0.26458,4.97417 v 6.87916 0.0265 l -0.0265,5.66208 v 1.05834 h -0.0265 -0.21167 -4.89479 -0.21167 -3.01625 l -0.0529,-0.0265 -0.0265,-0.21167 v -0.10583 l 0.0794,-0.3175 0.0265,-0.50271 0.23813,-1.05833 0.0529,-0.68792 0.18521,-0.37042 0.0265,-0.13229 v -0.60854 l 0.0794,-0.42333 v -0.10584 l -0.0794,-0.42333 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 v -0.0794 l -0.0265,-0.0529 -0.0265,-0.0529 -0.0529,-0.0794 0.10583,-0.39688 V 156.21 l -0.0265,-0.10583 -0.10584,-0.21167 -0.0265,-0.0794 -0.0265,-0.10583 -0.0794,-0.26458 v -0.0794 l 0.0529,-0.34396 -0.0265,-0.13229 -0.13229,-0.50271 -0.66146,-1.29645 -0.23813,-0.29105 -0.0529,-0.10583 -0.0265,-0.13229 -0.13229,-0.42333 -0.0265,-0.58209 -0.0529,-0.13229 -0.0794,-0.0529 -0.0529,-0.10583 0.0794,-0.13229 -0.0794,-0.13229 V 150.495 l -0.0529,-0.15875 v -0.0794 l 0.0794,-0.13229 0.0265,-0.10583 0.0265,-0.0265 h 0.0264 l 0.0794,0.0529 h 0.0265 l 0.0794,-0.10584 0.0265,-0.13229 0.0265,-0.34396 0.0265,-0.21166 v -0.10584 l -0.10584,-0.15875 -0.0264,-0.21166 -0.0529,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 -0.0794,-0.10583 -0.0529,-0.29104 -0.0529,-0.1323 -0.10583,-0.0794 -0.0794,-0.0794 -0.18521,-0.13229 -0.10583,-0.0794 -0.0529,-0.0794 v -0.13229 l -0.13229,-0.44979 -0.13229,-0.21167 -0.0794,-0.29104 -0.0265,-0.0794 -0.07938,-0.21167 -0.02646,-0.0794 -0.105833,-0.10583 -0.529167,-0.89958 -0.05292,-0.15875 v -0.13229 -0.29105 -0.10583 l -0.132291,-0.3175 v -0.23812 l -0.05292,-0.0529 -0.02646,-0.18521 0.02646,-0.50271 -0.02646,-0.60854 -0.05292,-0.13229 -0.02646,-0.0529 v -0.0794 l 0.02646,-0.13229 v -0.0794 l -0.105833,-0.26458 -0.05292,-0.18521 0.02646,-0.0794 0.105834,-0.0794 v -0.13229 l -0.07937,-0.29104 0.02646,-0.0529 0.02646,-0.18521 0.105834,-0.18521 -0.02646,-0.0794 -0.05292,-0.0794 -0.02646,-0.10583 -0.02646,-0.10583 -0.05292,-0.0529 -0.05292,-0.0529 -0.05292,-0.0794 -0.05292,-0.0529 -0.02646,-0.10583 -0.105833,-0.52917 -0.02646,-0.0794 v -0.10584 l -0.07937,-0.18521 -0.02646,-0.1852 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0265 v -0.10584 -0.0529 l -0.05292,-0.0794 -0.05292,-0.0529 -0.132291,-0.10584 -0.132292,-0.58208 -0.02646,-0.29104 v -0.42334 -0.21166 -0.39688 -0.10583 l -0.02646,-0.13229 0.02646,-0.0529 v -0.0794 l 0.07937,-0.29105 0.02646,-0.0265 v -0.0529 l -0.02646,-0.0794 v -0.0529 l -0.02646,-0.0265 v -0.0265 l 0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.15875 v -0.0794 -0.0265 -0.0529 l 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.132292,-0.0794 0.238125,0.0529 0.15875,0.0529 h 0.07937 l 0.132292,0.0265 0.449791,-0.0529 0.185209,-0.0529 h 0.05292 0.07937 l 0.105834,0.0265 0.07937,0.0265 0.05292,0.0265 0.05292,0.0794 h 0.02646 l 0.02646,0.0265 h 0.05292 l 0.185209,0.0529 z" | ||||
|        id="ARD" | ||||
|        name="San Luis" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 4.9 KiB | 
| After Width: | Height: | Size: 12 KiB | 
| After Width: | Height: | Size: 5.8 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="19.023661mm" | ||||
|    height="28.363354mm" | ||||
|    viewBox="0 0 19.023661 28.363354" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-95.24991,-134.40833)"> | ||||
|     <path | ||||
|        d="m 99.059995,138.29771 0.02646,-0.21167 0.02646,-0.0265 0.185208,-0.39687 0.05292,-0.10584 v -0.0529 -0.21167 l 0.02646,-0.0529 0.02646,-0.10583 1.164167,-2.59292 0.10583,-0.13229 0.21167,0.0265 1.05833,0.34396 0.18521,0.0529 2.88396,0.0529 h 9.02229 l 0.15875,0.50271 0.0794,2.46063 v 0.68791 3.28084 6.27062 1.16417 l -0.3175,2.2225 -0.52917,3.4925 -0.47625,3.04271 -0.26458,1.66687 -0.47625,3.09563 -0.84667,-2.2225 -0.26458,-0.15875 -0.89959,-0.0529 -1.85208,0.0265 h -3.12208 l -0.1323,-0.0265 -0.0264,-0.0529 -0.0265,-0.0265 v -0.10583 -0.0529 -0.0264 l -0.0529,-0.0265 -0.0529,-0.0265 -0.1852,-0.0265 h -0.0529 l -0.0265,-0.0529 v -0.0264 l 0.0265,-0.18521 v -0.0265 -0.0265 -0.0265 l -0.0265,-0.0265 -0.0265,-0.0265 h -0.0529 l -0.21167,0.0529 -0.0529,-0.0265 -0.39687,-0.10583 -0.0794,-0.0265 -0.635,0.10584 h -0.10583 l -0.3175,-0.0265 h -0.0529 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0794,-0.0529 -0.0265,-0.0265 -0.0529,-0.0265 -0.34396,-0.0529 -0.0265,-0.0265 -0.0529,-0.0529 -0.0529,-0.0794 -0.0265,-0.0265 -0.39687,-0.23812 -0.0794,-0.0529 -0.0265,-0.0529 v -0.0794 l 0.0265,-0.15875 -0.0265,-0.0794 v -0.0265 l -0.0529,-0.0265 -1.08479,-0.26458 h -0.21167 l -0.608543,0.13229 -1.031875,0.29104 -1.5875,0.39687 -0.132292,-0.15875 -0.05292,-0.13229 V 158.67 l -0.05292,-0.13229 -0.370417,-0.635 -0.238125,-0.58208 -0.238125,-1.82563 0.02646,-0.52916 -0.105833,-1.21709 v -0.10583 l -0.02646,-0.0265 -0.02646,-0.0265 -0.105833,-0.10583 -0.211667,-0.13229 h -0.05292 l -0.02646,-0.0265 v -0.0265 l -0.02646,-0.0265 v -0.21166 -0.0529 l 0.02646,-0.0265 0.105834,-0.0529 0.02646,-0.0265 0.05292,-0.13229 0.07937,-0.1323 0.132291,-0.13229 0.07937,-0.10583 0.05292,-0.15875 0.05292,-0.34396 -0.05292,-0.92604 -0.47625,-2.27542 0.05292,-0.0529 0.07937,-0.0265 h 0.02646 0.05292 l 0.07937,0.0265 h 0.05292 0.07937 l 0.05292,-0.0265 0.05292,-0.0529 0.05292,-0.10583 0.15875,-0.50271 0.05292,-0.0529 0.02646,-0.0265 h 0.07937 0.02646 l -0.02646,-0.0794 -0.132291,-0.3175 -0.238125,-0.55563 v -0.13229 l 0.05292,-0.18521 h 0.05292 l 0.02646,-0.0264 h 0.105834 0.05292 0.02646 0.02646 l 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.05292,-0.10583 0.02646,-0.0529 v -0.0529 h -0.02646 l -0.105833,-0.0794 -0.105833,-0.0529 h -0.07937 l -0.105834,-0.0265 -0.07937,-0.0529 -0.02646,-0.0265 0.343958,-0.39687 0.07937,-0.0794 0.264584,-0.3175 0.07937,-0.0794 0.05292,-0.0265 0.132292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.132291,-0.37042 0.02646,-0.0265 0.105833,-0.13229 0.15875,-0.44979 0.05292,-0.26459 0.07937,-0.34395 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.02646,-0.0529 h 0.05292 l 0.02646,-0.0265 0.07937,-0.15875 0.185209,-0.76729 0.07937,-0.26458 0.02646,-0.10584 H 98.081 l 0.02646,-0.0265 h 0.07937 0.07937 0.02646 l 0.02646,-0.0529 0.02646,-0.0529 0.15875,-0.58208 0.02646,-0.0529 0.07937,-0.0265 0.343959,0.0529 0.02646,-0.0265 v -0.10583 -0.23813 l -0.02646,-0.44979 -0.105834,-0.635 v -0.0529 l 0.02646,-0.0529 0.185208,-0.66146 0.02646,-0.18521 z" | ||||
|        id="ARG" | ||||
|        name="Santiago del Estero" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 3.4 KiB | 
| After Width: | Height: | Size: 5.7 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="9.3935156mm" | ||||
|    height="12.197932mm" | ||||
|    viewBox="0 0 9.3935156 12.197932" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-100.01234,-142.34548)"> | ||||
|     <path | ||||
|        d="m 100.77979,143.45708 1.61396,0.26459 0.1852,0.0265 0.0529,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0264,-0.0529 v -0.0265 -0.0265 -0.0529 -0.21167 -0.0794 l 0.0265,-0.13229 0.0529,-0.10584 v -0.13229 -0.23812 -0.0794 l 0.0265,-0.0794 v -0.0265 l 0.0265,-0.0265 0.0529,-0.0265 h 0.0265 l 0.0265,0.0265 0.13229,0.0529 0.0265,0.0265 0.26458,0.0794 0.13229,-0.0265 h 0.0794 l 0.52917,0.13229 h 0.0794 0.0265 l 0.0265,-0.0264 0.29104,-0.15875 0.15875,-0.0529 0.0529,-0.0265 h 0.0529 l 0.0265,0.0265 h 0.0265 l 0.0265,0.0265 0.0794,0.15875 0.0265,0.15875 0.0529,0.0794 0.0265,0.0794 0.0794,0.0529 1.05833,0.42334 0.0794,-0.0265 h 0.0529 l 0.10583,0.0265 0.10583,0.0794 0.18521,0.0794 0.10583,0.0265 0.0794,0.0265 0.0265,-0.0265 h 0.0794 l 0.0529,-0.0265 h 0.0265 l 0.10583,-0.0794 0.10583,-0.10583 0.0529,-0.0265 0.26458,-0.10583 0.18521,-0.0265 0.29104,0.0794 h 0.0794 l 1.16417,-0.0265 0.0264,0.42333 -0.0264,0.18521 -0.18521,0.66146 -0.0265,0.0529 v 0.0529 l 0.10583,0.635 0.0265,0.44979 v 0.23813 0.10583 l -0.0265,0.0265 -0.34396,-0.0529 -0.0794,0.0265 -0.0265,0.0529 -0.15875,0.58208 -0.0265,0.0529 -0.0265,0.0529 h -0.0265 -0.0794 -0.0794 l -0.0265,0.0265 h -0.0529 l -0.0265,0.10583 -0.0794,0.26459 -0.18521,0.76729 -0.0794,0.15875 -0.0264,0.0265 h -0.0529 l -0.0265,0.0529 -0.0529,0.0529 -0.0264,0.0529 -0.0265,0.0529 -0.0794,0.34396 -0.0529,0.26459 -0.15875,0.44979 -0.10584,0.13229 -0.0265,0.0265 -0.13229,0.37041 -0.0265,0.0529 -0.0264,0.0265 -0.1323,0.0265 -0.0529,0.0265 -0.0794,0.0794 -0.26458,0.3175 -0.0794,0.0794 -0.34395,0.39687 0.0264,0.0265 0.0794,0.0529 0.10583,0.0265 h 0.0794 l 0.10583,0.0529 0.10583,0.0794 h 0.0265 v 0.0529 l -0.0265,0.0529 -0.0529,0.10583 -0.0265,0.0529 -0.0265,0.0265 -0.0529,0.0265 -0.0265,0.0265 h -0.0265 -0.0265 -0.0529 -0.10583 l -0.0265,0.0265 h -0.0529 l -0.0529,0.18521 v 0.13229 l 0.23812,0.55563 0.13229,0.3175 0.0265,0.0794 h -0.0265 -0.0794 l -0.0265,0.0265 -0.0529,0.0529 -0.15875,0.50271 -0.0529,0.10583 -0.0529,0.0529 -0.0529,0.0265 h -0.0794 -0.0529 l -0.0794,-0.0265 h -0.0529 -0.0265 l -0.0794,0.0265 -0.0529,0.0529 -0.15875,0.0794 h -0.10583 -0.0794 l -0.0794,-0.0265 -0.10584,-0.0529 -0.10583,-0.0794 -0.10583,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 h -0.0529 -0.0529 l -0.44979,0.29104 -0.15875,0.18521 -0.18521,0.13229 -0.0529,0.0265 -0.13229,0.21167 -0.1323,0.29104 -0.0265,0.0529 -0.0794,-0.0264 -0.44979,-0.58209 -0.0529,-0.0794 -0.0794,-0.23813 -0.0529,-0.3175 -0.0529,-0.18521 -0.0264,-0.0265 -0.0529,-0.0529 -0.23813,-0.13229 h -0.10583 l -0.0529,0.0265 -0.0529,0.0265 -0.13229,0.0529 -0.10584,-0.0794 -0.0265,-0.0265 -0.0529,-0.0529 -0.0265,-0.0794 v -0.1323 l -0.0794,-0.23812 -0.0264,-0.0794 -0.0265,-0.0264 -0.0794,-0.0529 -0.0529,-0.0265 -0.0794,-0.0794 -0.0265,-0.0265 -0.0265,-0.0529 -0.0264,-0.0529 -0.0265,-0.26458 -0.0794,-0.15875 -0.18521,-0.89958 0.0529,-0.15875 v -0.0265 l -0.0265,-0.0529 h -0.0265 -0.0264 l -0.15875,-0.0265 -0.39688,-0.23812 -0.0794,-0.0265 -0.18521,-0.0265 -0.10584,-0.0265 -0.10583,-0.0264 -0.0529,-0.0529 -0.0264,-0.0265 v -0.0265 l 0.0264,-0.0265 0.1323,-0.21166 0.10583,-0.23813 0.15875,-0.18521 0.13229,-0.10583 0.10583,-0.13229 0.0265,-0.0529 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 0.10583,-0.18521 0.13229,-0.1852 0.18521,-0.18521 0.37042,-0.39688 0.15875,-0.23812 0.0529,-0.15875 0.0265,-0.0794 v -0.0794 -0.34396 -0.0794 l 0.0794,-0.37041 v -0.0529 -0.0794 -0.0529 -0.0529 l -0.0265,-0.0265 v -0.0264 l -0.0265,-0.0529 h -0.0265 l -0.0265,-0.0265 -0.23812,-0.13229 -0.37042,-0.29104 -0.18521,-0.18521 -0.0794,-0.0529 -0.13229,-0.0529 -0.13229,-0.0529 h -0.10584 l -0.13229,-0.0529 -0.0794,-0.0529 -0.0794,-0.1323 -0.0265,-0.0794 v -0.0529 -0.10583 l 0.10584,-0.29104 0.13229,-0.26459 0.0265,-0.15875 0.0264,-0.13229 v -0.10583 l 0.0265,-0.13229 0.0265,-0.0794 0.0794,-0.0794 0.0529,-0.0794 0.0529,-0.0265 z" | ||||
|        id="ART" | ||||
|        name="Tucumán" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 4.3 KiB | 
| @@ -1,42 +1,32 @@ | ||||
| #root { | ||||
| /* src/App.css */ | ||||
|  | ||||
| .container-legislativas2025 { | ||||
|   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 { | ||||
| @keyframes elecciones-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; | ||||
|   .container-legislativas2025 a:nth-of-type(2) .logo { | ||||
|     animation: elecciones-logo-spin infinite 20s linear; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .card { | ||||
| .container-legislativas2025 .card { | ||||
|   padding: 2em; | ||||
| } | ||||
|  | ||||
| .read-the-docs { | ||||
| .container-legislativas2025 .read-the-docs { | ||||
|   color: #888; | ||||
| } | ||||
| } | ||||
| @@ -1,27 +1,66 @@ | ||||
| // src/App.tsx | ||||
| import './App.css' | ||||
| import { BancasWidget } from './components/BancasWidget' | ||||
| /*import { BancasWidget } from './components/BancasWidget' | ||||
| import { CongresoWidget } from './components/CongresoWidget' | ||||
| import MapaBsAs from './components/MapaBsAs' | ||||
| import { TickerWidget } from './components/TickerWidget' | ||||
| import { DipSenTickerWidget } from './components/DipSenTickerWidget' | ||||
| import { TelegramaWidget } from './components/TelegramaWidget' | ||||
| import { ConcejalesWidget } from './components/ConcejalesWidget' | ||||
| import MapaBsAsSecciones from './components/MapaBsAsSecciones' | ||||
|  | ||||
| import { SenadoresWidget } from './components/SenadoresWidget' | ||||
| import { DiputadosWidget } from './components/DiputadosWidget' | ||||
| import { ResumenGeneralWidget } from './components/ResumenGeneralWidget' | ||||
| import { SenadoresTickerWidget } from './components/SenadoresTickerWidget' | ||||
| import { DiputadosTickerWidget } from './components/DiputadosTickerWidget' | ||||
| import { ConcejalesTickerWidget } from './components/ConcejalesTickerWidget' | ||||
| import { DiputadosPorSeccionWidget } from './components/DiputadosPorSeccionWidget' | ||||
| import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidget' | ||||
| import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget' | ||||
| import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget' | ||||
| import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget' | ||||
| */ | ||||
| function App() { | ||||
|   return ( | ||||
|   return ({/* | ||||
|     <> | ||||
|      | ||||
|       <h1>Resultados Electorales - Provincia de Buenos Aires</h1> | ||||
|       <main> | ||||
|         <TickerWidget /> | ||||
|       <main className="space-y-6"> | ||||
|         <ResumenGeneralWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <SenadoresTickerWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <DiputadosTickerWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <ConcejalesTickerWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <DipSenTickerWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <SenadoresPorSeccionWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <DiputadosPorSeccionWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <ConcejalesPorSeccionWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <SenadoresWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <DiputadosWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <ConcejalesWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <CongresoWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <BancasWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <MapaBsAs /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <MapaBsAsSecciones /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <TelegramaWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <ResultadosTablaDetalladaWidget /> | ||||
|         <hr className="border-gray-300" /> | ||||
|         <ResultadosRankingMunicipioWidget /> | ||||
|       </main> | ||||
|     </> | ||||
|     </>*/} | ||||
|   ) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,31 @@ | ||||
| // src/apiService.ts | ||||
| import axios from 'axios'; | ||||
| import type { ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker } from './types/types'; | ||||
| import type { | ||||
|   ApiResponseRankingMunicipio, ApiResponseRankingSeccion, | ||||
|   ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, | ||||
|   TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, | ||||
|   ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia, | ||||
|   CategoriaResumenHome, ResultadoFila, ResultadoSeccion, | ||||
|   ProvinciaResumen | ||||
| } from './types/types'; | ||||
|  | ||||
| const API_BASE_URL = 'http://localhost:5217/api'; | ||||
| /** | ||||
|  * URL base para las llamadas a la API. | ||||
|  * - En desarrollo, apunta directamente al backend de .NET. | ||||
|  * - En producción, apunta al endpoint público de la API. | ||||
|  */ | ||||
| export const API_BASE_URL = import.meta.env.DEV | ||||
|   ? 'http://localhost:5217/api' | ||||
|   : 'https://elecciones2025.eldia.com/api'; | ||||
|  | ||||
| /** | ||||
|  * URL base para los activos estáticos (imágenes, etc.) de la carpeta `public`. | ||||
|  * - En desarrollo, es una ruta relativa a la raíz (servida por Vite). | ||||
|  * - En producción, es la URL absoluta del dominio donde se alojan los widgets. | ||||
|  */ | ||||
| export const assetBaseUrl = import.meta.env.DEV | ||||
|   ? '' | ||||
|   : 'https://elecciones2025.eldia.com'; | ||||
|  | ||||
| const apiClient = axios.create({ | ||||
|   baseURL: API_BASE_URL, | ||||
| @@ -57,7 +80,6 @@ export interface BancadaDetalle { | ||||
| export interface ConfiguracionPublica { | ||||
|   TickerResultadosCantidad?: string; | ||||
|   ConcejalesResultadosCantidad?: string; | ||||
|   // ... otras claves públicas que pueda añadir en el futuro | ||||
| } | ||||
|  | ||||
| export interface ResultadoDetalleSeccion { | ||||
| @@ -68,21 +90,58 @@ export interface ResultadoDetalleSeccion { | ||||
|   color: string | null; | ||||
| } | ||||
|  | ||||
| export const getResumenProvincial = async (): Promise<CategoriaResumen[]> => { | ||||
|   const response = await apiClient.get('/resultados/provincia/02'); | ||||
| export interface PartidoComposicionNacional { | ||||
|   id: string; | ||||
|   nombre: string; | ||||
|   nombreCorto: string | null; | ||||
|   color: string | null; | ||||
|   bancasFijos: number; | ||||
|   bancasGanadas: number; | ||||
|   bancasTotales: number; | ||||
|   ordenDiputadosNacionales: number | null; | ||||
|   ordenSenadoresNacionales: number | null; | ||||
| } | ||||
|  | ||||
| export interface CamaraComposicionNacional { | ||||
|   camaraNombre: string; | ||||
|   totalBancas: number; | ||||
|   bancasEnJuego: number; | ||||
|   partidos: PartidoComposicionNacional[]; | ||||
|   presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null; | ||||
|   ultimaActualizacion: string; | ||||
| } | ||||
|  | ||||
| export interface ComposicionNacionalData { | ||||
|   diputados: CamaraComposicionNacional; | ||||
|   senadores: CamaraComposicionNacional; | ||||
| } | ||||
|  | ||||
| export interface ResumenParams { | ||||
|   focoDistritoId?: string; | ||||
|   focoCategoriaId?: number; | ||||
|   cantidadResultados?: number; | ||||
| } | ||||
|  | ||||
| export const getResumenProvincial = async (eleccionId: number): Promise<CategoriaResumen[]> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/provincia/02`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getBancasPorSeccion = async (seccionId: string): Promise<ProyeccionBancas> => { | ||||
|   const response = await apiClient.get(`/resultados/bancas/${seccionId}`); | ||||
|   return response.data; | ||||
| export const getBancasPorSeccion = async (eleccionId: number, seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => { | ||||
|   const { data } = await apiClient.get(`/elecciones/${eleccionId}/bancas-por-seccion/${seccionId}/${camara}`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Obtiene la lista de Secciones Electorales desde la API. | ||||
|  */ | ||||
| export const getSeccionesElectorales = async (): Promise<MunicipioSimple[]> => { | ||||
|   const response = await apiClient.get('/catalogos/secciones-electorales'); | ||||
| export const getSeccionesElectorales = async (categoriaId?: number): Promise<MunicipioSimple[]> => { | ||||
|   let url = '/catalogos/secciones-electorales'; | ||||
|   // Si se proporciona una categoría, la añadimos a la URL | ||||
|   if (categoriaId) { | ||||
|     url += `?categoriaId=${categoriaId}`; | ||||
|   } | ||||
|   const response = await apiClient.get(url); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -119,13 +178,13 @@ export const getMesasPorEstablecimiento = async (establecimientoId: string): Pro | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getComposicionCongreso = async (): Promise<ComposicionData> => { | ||||
|   const response = await apiClient.get('/resultados/composicion-congreso'); | ||||
| export const getComposicionCongreso = async (eleccionId: number): Promise<ComposicionData> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-congreso`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getBancadasDetalle = async (): Promise<BancadaDetalle[]> => { | ||||
|   const response = await apiClient.get('/resultados/bancadas-detalle'); | ||||
| export const getBancadasDetalle = async (eleccionId: number): Promise<BancadaDetalle[]> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/bancadas-detalle`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| @@ -134,24 +193,169 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> = | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getResultadosConcejales = async (seccionId: string): Promise<ResultadoTicker[]> => { | ||||
|   const response = await apiClient.get(`/resultados/concejales/${seccionId}`); | ||||
| export const getResultadosPorSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getDetalleSeccion = async (seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => { | ||||
|     const response = await apiClient.get(`/resultados/seccion/${seccionId}?categoriaId=${categoriaId}`); | ||||
|     return response.data; | ||||
| export const getDetalleSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/seccion/${seccionId}?categoriaId=${categoriaId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getResultadosConcejalesPorMunicipio = async (municipioId: string): Promise<ResultadoTicker[]> => { | ||||
|   // Usamos el endpoint 'partido' que, según la aclaración de la API, busca por municipio | ||||
|   const response = await apiClient.get(`/resultados/partido/${municipioId}`); | ||||
|   // La API devuelve un objeto, nosotros extraemos el array de resultados | ||||
| export const getResultadosPorMunicipio = async (eleccionId: number, municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/partido/${municipioId}?categoriaId=${categoriaId}`); | ||||
|   return response.data.resultados; | ||||
| }; | ||||
|  | ||||
| export const getMunicipios = async (): Promise<MunicipioSimple[]> => { | ||||
|   const response = await apiClient.get('/catalogos/municipios'); | ||||
| export const getMunicipios = async (categoriaId?: number): Promise<MunicipioSimple[]> => { | ||||
|   let url = '/catalogos/municipios'; | ||||
|   if (categoriaId) { | ||||
|     url += `?categoriaId=${categoriaId}`; | ||||
|   } | ||||
|   const response = await apiClient.get(url); | ||||
|  | ||||
|   // --- CORRECCIÓN --- | ||||
|   // La API devuelve un array de objetos con las propiedades { id, nombre }. | ||||
|   // Ya no es necesario mapear. Simplemente devolvemos los datos como vienen. | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getSeccionesElectoralesConCargos = async (): Promise<MunicipioSimple[]> => { | ||||
|   // Hacemos la petición al nuevo endpoint del backend | ||||
|   const { data } = await apiClient.get<MunicipioSimple[]>('/resultados/secciones-electorales-con-cargos'); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getResultadosTablaDetallada = async (seccionId: string): Promise<ApiResponseTablaDetallada> => { | ||||
|   const { data } = await apiClient.get(`/resultados/tabla-ranking-seccion/${seccionId}`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getRankingResultadosPorSeccion = async (seccionId: string): Promise<ApiResponseRankingSeccion> => { | ||||
|   const { data } = await apiClient.get(`/resultados/ranking-por-seccion/${seccionId}`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getRankingMunicipiosPorSeccion = async (seccionId: string): Promise<ApiResponseRankingMunicipio> => { | ||||
|   const { data } = await apiClient.get(`/resultados/ranking-municipios-por-seccion/${seccionId}`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getEstablecimientosPorMunicipio = async (municipioId: string): Promise<CatalogoItem[]> => { | ||||
|   const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getPanelElectoral = async ( | ||||
|   eleccionId: number, | ||||
|   ambitoId: string | null, | ||||
|   categoriaId: number, | ||||
|   nivel: 'pais' | 'provincia' | 'municipio' | ||||
| ): Promise<PanelElectoralDto> => { | ||||
|  | ||||
|   let url: string; | ||||
|  | ||||
|   // Construimos la URL con el prefijo correcto. | ||||
|   if (nivel === 'pais' || !ambitoId) { | ||||
|     url = `/elecciones/${eleccionId}/panel`; | ||||
|   } else if (nivel === 'provincia') { | ||||
|     url = `/elecciones/${eleccionId}/panel/distrito:${ambitoId}`; | ||||
|   } else { // nivel === 'municipio' | ||||
|     url = `/elecciones/${eleccionId}/panel/municipio:${ambitoId}`; | ||||
|   } | ||||
|  | ||||
|   url += `?categoriaId=${categoriaId}`; | ||||
|  | ||||
|   try { | ||||
|     const { data } = await apiClient.get(url); | ||||
|     return data; | ||||
|   } catch (error) { | ||||
|     if (axios.isAxiosError(error) && error.response?.status === 404) { | ||||
|       console.warn(`API devolvió 404 para ${url}. Devolviendo un estado vacío.`); | ||||
|       return { | ||||
|         ambitoNombre: 'Sin Datos', | ||||
|         mapaData: [], | ||||
|         resultadosPanel: [], | ||||
|         estadoRecuento: { participacionPorcentaje: 0, mesasTotalizadasPorcentaje: 0 }, | ||||
|         sinDatos: true, | ||||
|       }; | ||||
|     } | ||||
|     throw error; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => { | ||||
|   const { data } = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| // 11. Endpoint para el widget de tarjetas nacionales | ||||
| export const getResumenPorProvincia = async (eleccionId: number, params: ResumenParams = {}): Promise<ResumenProvincia[]> => { | ||||
|   // Usamos URLSearchParams para construir la query string de forma segura y limpia | ||||
|   const queryParams = new URLSearchParams(); | ||||
|  | ||||
|   if (params.focoDistritoId) { | ||||
|     queryParams.append('focoDistritoId', params.focoDistritoId); | ||||
|   } | ||||
|   if (params.focoCategoriaId) { | ||||
|     queryParams.append('focoCategoriaId', params.focoCategoriaId.toString()); | ||||
|   } | ||||
|   if (params.cantidadResultados) { | ||||
|     queryParams.append('cantidadResultados', params.cantidadResultados.toString()); | ||||
|   } | ||||
|  | ||||
|   const queryString = queryParams.toString(); | ||||
|  | ||||
|   // Añadimos la query string a la URL solo si tiene contenido | ||||
|   const url = `/elecciones/${eleccionId}/resumen-por-provincia${queryString ? `?${queryString}` : ''}`; | ||||
|  | ||||
|   const { data } = await apiClient.get(url); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getMunicipiosPorDistrito = async (distritoId: string): Promise<CatalogoItem[]> => { | ||||
|   const response = await apiClient.get(`/catalogos/municipios-por-distrito/${distritoId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise<CategoriaResumenHome> => { | ||||
|   const queryParams = new URLSearchParams({ | ||||
|     eleccionId: eleccionId.toString(), | ||||
|     distritoId: distritoId, | ||||
|     categoriaId: categoriaId.toString(), | ||||
|   }); | ||||
|   const url = `/elecciones/home-resumen?${queryParams.toString()}`; | ||||
|   const { data } = await apiClient.get(url); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getHomeResumenNacional = async (eleccionId: number, categoriaId: number): Promise<CategoriaResumenHome> => { | ||||
|   const queryParams = new URLSearchParams({ | ||||
|     eleccionId: eleccionId.toString(), | ||||
|     categoriaId: categoriaId.toString(), | ||||
|   }); | ||||
|   const url = `/elecciones/home-resumen-nacional?${queryParams.toString()}`; | ||||
|   const { data } = await apiClient.get(url); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getTablaConurbano = async (eleccionId: number): Promise<ResultadoFila[]> => { | ||||
|   const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-conurbano`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getTablaSecciones = async (eleccionId: number): Promise<ResultadoSeccion[]> => { | ||||
|   const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-secciones`); | ||||
|   return data; | ||||
| }; | ||||
|  | ||||
| export const getResumenNacionalPorProvincia = async (eleccionId: number, categoriaId: number): Promise<ProvinciaResumen[]> => { | ||||
|   const response = await apiClient.get(`/elecciones/${eleccionId}/resumen-nacional-por-provincia?categoriaId=${categoriaId}`); | ||||
|   return response.data; | ||||
| }; | ||||
|  | ||||
| export const getProvincias = async (): Promise<CatalogoItem[]> => { | ||||
|   const response = await apiClient.get('/catalogos/provincias'); | ||||
|   return response.data; | ||||
| }; | ||||
| @@ -1,41 +0,0 @@ | ||||
| /* src/components/BancasWidget.css */ | ||||
| .bancas-widget-container { | ||||
|     /* Mismo estilo de tarjeta que el Ticker */ | ||||
|     background-color: #ffffff; | ||||
|     border: 1px solid #e0e0e0; | ||||
|     box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|     padding: 15px 20px; | ||||
|     border-radius: 8px; | ||||
|     max-width: 800px; | ||||
|     margin: 20px auto; | ||||
|     color: #333333; | ||||
| } | ||||
|  | ||||
| .bancas-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| .bancas-header h4 { | ||||
|     margin: 0; | ||||
|     color: #212529; | ||||
|     font-size: 1.2em; | ||||
|     font-weight: 700; | ||||
| } | ||||
|  | ||||
| .bancas-header select { | ||||
|     background-color: #ffffff; | ||||
|     color: #333333; | ||||
|     border: 1px solid #ced4da; /* Borde estándar para inputs */ | ||||
|     border-radius: 4px; | ||||
|     padding: 5px 10px; | ||||
|     font-family: inherit; | ||||
|     font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .waffle-chart-container { | ||||
|     height: 300px; | ||||
|     font-family: "Public Sans", system-ui, sans-serif; | ||||
| } | ||||
| @@ -1,127 +0,0 @@ | ||||
| // src/components/BancasWidget.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| // Se cambia la importación: de ResponsiveWaffle a ResponsiveBar | ||||
| import { ResponsiveBar } from '@nivo/bar'; | ||||
| import { getBancasPorSeccion, getSeccionesElectorales } from '../apiService'; | ||||
| import type { ProyeccionBancas, MunicipioSimple } from '../types/types'; | ||||
| import './BancasWidget.css'; | ||||
|  | ||||
| // La paleta de colores se mantiene para consistencia visual | ||||
| const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"]; | ||||
|  | ||||
| export const BancasWidget = () => { | ||||
|     const [secciones, setSecciones] = useState<MunicipioSimple[]>([]); | ||||
|     const [seccionActual, setSeccionActual] = useState<string>(''); | ||||
|     const [data, setData] = useState<ProyeccionBancas | null>(null); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|     const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|     // useEffect para cargar la lista de secciones una sola vez | ||||
|     useEffect(() => { | ||||
|         const fetchSecciones = async () => { | ||||
|             try { | ||||
|                 const seccionesData = await getSeccionesElectorales(); | ||||
|                 if (seccionesData && seccionesData.length > 0) { | ||||
|                      | ||||
|                     // --- INICIO DE LA LÓGICA DE ORDENAMIENTO --- | ||||
|                     const orden = new Map([ | ||||
|                         ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|                         ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|                     ]); | ||||
|  | ||||
|                     const getOrden = (nombre: string) => { | ||||
|                         const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|                         return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|                     }; | ||||
|  | ||||
|                     seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|                     // --- FIN DE LA LÓGICA DE ORDENAMIENTO --- | ||||
|  | ||||
|                     setSecciones(seccionesData); | ||||
|                     setSeccionActual(seccionesData[0].id); | ||||
|                 } else { | ||||
|                     setError("No se encontraron secciones electorales."); | ||||
|                 } | ||||
|             } catch (err) { | ||||
|                 console.error("Error cargando secciones electorales:", err); | ||||
|                 setError("No se pudo cargar la lista de secciones."); | ||||
|             } | ||||
|         }; | ||||
|         fetchSecciones(); | ||||
|     }, []); | ||||
|  | ||||
|     // useEffect para cargar los datos de bancas cuando cambia la sección | ||||
|     useEffect(() => { | ||||
|         if (!seccionActual) return; | ||||
|  | ||||
|         const fetchData = async () => { | ||||
|             setLoading(true); | ||||
|             setError(null); | ||||
|             try { | ||||
|                 const result = await getBancasPorSeccion(seccionActual); | ||||
|                 setData(result); | ||||
|             } catch (err) { | ||||
|                 console.error(`Error cargando datos de bancas para sección ${seccionActual}:`, err); | ||||
|                 setData(null); | ||||
|                 setError("No hay datos de bancas disponibles para esta sección."); | ||||
|             } finally { | ||||
|                 setLoading(false); | ||||
|             } | ||||
|         }; | ||||
|         fetchData(); | ||||
|     }, [seccionActual]); | ||||
|  | ||||
|     // Se preparan los datos para el gráfico de barras. | ||||
|     // Se invierte el array para que el partido con más bancas aparezca arriba. | ||||
|     const barData = data ? [...data.proyeccion].reverse() : []; | ||||
|  | ||||
|     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)} disabled={secciones.length === 0}> | ||||
|                     {secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)} | ||||
|                 </select> | ||||
|             </div> | ||||
|             <div className="waffle-chart-container"> | ||||
|                 {loading ? <p>Cargando...</p> : error ? <p>{error}</p> : | ||||
|                 // --- SE REEMPLAZA EL GRÁFICO WAFFLE POR EL GRÁFICO DE BARRAS --- | ||||
|                 <ResponsiveBar | ||||
|                     data={barData} | ||||
|                     keys={['bancas']} | ||||
|                     indexBy="agrupacionNombre" | ||||
|                     layout="horizontal" | ||||
|                     margin={{ top: 10, right: 30, bottom: 25, left: 160 }} | ||||
|                     padding={0.3} | ||||
|                     valueScale={{ type: 'linear' }} | ||||
|                     indexScale={{ type: 'band', round: true }} | ||||
|                     colors={({ index }) => NIVO_COLORS[index % NIVO_COLORS.length]} | ||||
|                     borderColor={{ from: 'color', modifiers: [['darker', 1.6]] }} | ||||
|                     axisTop={null} | ||||
|                     axisRight={null} | ||||
|                     axisBottom={{ | ||||
|                         tickSize: 5, | ||||
|                         tickPadding: 5, | ||||
|                         tickRotation: 0, | ||||
|                         legend: 'Cantidad de Bancas', | ||||
|                         legendPosition: 'middle', | ||||
|                         legendOffset: 20, | ||||
|                         // Asegura que los ticks del eje sean números enteros | ||||
|                         format: (value) => Math.floor(value) === value ? value : '' | ||||
|                     }} | ||||
|                     axisLeft={{ | ||||
|                         tickSize: 5, | ||||
|                         tickPadding: 5, | ||||
|                         tickRotation: 0, | ||||
|                     }} | ||||
|                     labelSkipWidth={12} | ||||
|                     labelSkipHeight={12} | ||||
|                     labelTextColor={{ from: 'color', modifiers: [['darker', 3]] }} | ||||
|                     animate={true} | ||||
|                     // Se elimina la leyenda, ya que las etiquetas en el eje son suficientes | ||||
|                     legends={[]} | ||||
|                 />} | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,209 +0,0 @@ | ||||
| /* src/components/CongresoWidget.css */ | ||||
| .congreso-container { | ||||
|   display: flex; | ||||
|   /* Se reduce ligeramente el espacio entre el gráfico y el panel */ | ||||
|   gap: 1rem; | ||||
|   background-color: #ffffff; | ||||
|   border: 1px solid #e0e0e0; | ||||
|   box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|   padding: 1rem; | ||||
|   border-radius: 8px; | ||||
|   max-width: 800px; | ||||
|   margin: 20px auto; | ||||
|   font-family: "Public Sans", system-ui, sans-serif; | ||||
|   color: #333333; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .congreso-grafico { | ||||
|   /* --- CAMBIO PRINCIPAL: Se aumenta la proporción del gráfico --- */ | ||||
|   flex: 1 1 65%; | ||||
|   min-width: 300px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| .congreso-grafico svg { | ||||
|   width: 100%; | ||||
|   height: auto; | ||||
|   animation: fadeIn 0.8s ease-in-out; | ||||
| } | ||||
|  | ||||
| @keyframes fadeIn { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|     transform: scale(0.9); | ||||
|   } | ||||
|  | ||||
|   to { | ||||
|     opacity: 1; | ||||
|     transform: scale(1); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .congreso-summary { | ||||
|   /* --- CAMBIO PRINCIPAL: Se reduce la proporción del panel de datos --- */ | ||||
|   flex: 1 1 35%; | ||||
|   border-left: 1px solid #e0e0e0; | ||||
|   /* Se reduce el padding para dar aún más espacio al gráfico */ | ||||
|   padding-left: 1rem; | ||||
| } | ||||
|  | ||||
| .congreso-summary h3 { | ||||
|   margin-top: 0; | ||||
|   font-size: 1.4em; | ||||
|   color: #212529; | ||||
| } | ||||
|  | ||||
| .chamber-tabs { | ||||
|   display: flex; | ||||
|   margin-bottom: 1.5rem; | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 6px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .chamber-tabs button { | ||||
|   flex: 1; | ||||
|   padding: 0.75rem 0.5rem; | ||||
|   border: none; | ||||
|   background-color: #f8f9fa; | ||||
|   color: #6c757d; | ||||
|   font-family: inherit; | ||||
|   font-size: 1em; | ||||
|   font-weight: 500; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| .chamber-tabs button:first-child { | ||||
|   border-right: 1px solid #dee2e6; | ||||
| } | ||||
|  | ||||
| .chamber-tabs button:hover { | ||||
|   background-color: #e9ecef; | ||||
| } | ||||
|  | ||||
| .chamber-tabs button.active { | ||||
|   background-color: var(--primary-accent-color); | ||||
|   color: #ffffff; | ||||
| } | ||||
|  | ||||
| .summary-metric { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: baseline; | ||||
|   margin-bottom: 0.5rem; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .summary-metric strong { | ||||
|   font-size: 1.5em; | ||||
|   font-weight: 700; | ||||
|   color: var(--primary-accent-color); | ||||
| } | ||||
|  | ||||
| .congreso-summary hr { | ||||
|   border: none; | ||||
|   border-top: 1px solid #e0e0e0; | ||||
|   margin: 1.5rem 0; | ||||
| } | ||||
|  | ||||
| .partido-lista { | ||||
|   list-style: none; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| .partido-lista li { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   margin-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .partido-color-box { | ||||
|   width: 14px; | ||||
|   height: 14px; | ||||
|   border-radius: 3px; | ||||
|   margin-right: 10px; | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .partido-nombre { | ||||
|   flex-grow: 1; | ||||
| } | ||||
|  | ||||
| .partido-bancas { | ||||
|   font-weight: 700; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| /* --- Media Query para Responsividad Móvil --- */ | ||||
| @media (max-width: 768px) { | ||||
|   .congreso-container { | ||||
|     flex-direction: column; | ||||
|     padding: 1.5rem; | ||||
|   } | ||||
|  | ||||
|   .congreso-summary { | ||||
|     border-left: none; | ||||
|     padding-left: 0; | ||||
|     margin-top: 2rem; | ||||
|     border-top: 1px solid #e0e0e0; | ||||
|     padding-top: 1.5rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .seat-tooltip { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 5px; | ||||
|   padding: 5px; | ||||
|   background-color: white; | ||||
|   border-radius: 4px; | ||||
|   box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | ||||
| } | ||||
|  | ||||
| .seat-tooltip img { | ||||
|   width: 60px; | ||||
|   height: 60px; | ||||
|   border-radius: 50%; | ||||
|   object-fit: cover; | ||||
|   border: 2px solid #ccc; | ||||
| } | ||||
|  | ||||
| .seat-tooltip p { | ||||
|   margin: 0; | ||||
|   font-size: 12px; | ||||
|   font-weight: bold; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .seat-tooltip { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     gap: 5px; | ||||
|     padding: 8px; | ||||
|     background-color: white; | ||||
| } | ||||
| .seat-tooltip img { | ||||
|     width: 60px; | ||||
|     height: 60px; | ||||
|     border-radius: 50%; | ||||
|     object-fit: cover; | ||||
|     border: 2px solid #ccc; | ||||
| } | ||||
| .seat-tooltip p { | ||||
|     margin: 0; | ||||
|     font-size: 12px; | ||||
|     font-weight: bold; | ||||
|     color: #333; | ||||
| } | ||||
|  | ||||
| #seat-tooltip.react-tooltip { | ||||
|     opacity: 1 !important; | ||||
|     background-color: white; /* Opcional: asegura un fondo sólido */ | ||||
| } | ||||
| @@ -1,139 +0,0 @@ | ||||
| // src/components/TelegramaWidget.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { | ||||
|   getSecciones, | ||||
|   getMunicipiosPorSeccion, | ||||
|   getCircuitosPorMunicipio, | ||||
|   getEstablecimientosPorCircuito, | ||||
|   getMesasPorEstablecimiento, | ||||
|   getTelegramaPorId | ||||
| } from '../apiService'; | ||||
| import type { TelegramaData, CatalogoItem } from '../types/types'; | ||||
| import './TelegramaWidget.css'; | ||||
|  | ||||
| import { pdfjs, Document, Page } from 'react-pdf'; | ||||
| import 'react-pdf/dist/Page/AnnotationLayer.css'; | ||||
| import 'react-pdf/dist/Page/TextLayer.css'; | ||||
|  | ||||
| pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs'; | ||||
|  | ||||
| export const TelegramaWidget = () => { | ||||
|   // Estados para los filtros geográficos | ||||
|   const [secciones, setSecciones] = useState<CatalogoItem[]>([]); | ||||
|   const [municipios, setMunicipios] = useState<CatalogoItem[]>([]); | ||||
|   const [circuitos, setCircuitos] = useState<CatalogoItem[]>([]); | ||||
|   const [establecimientos, setEstablecimientos] = useState<CatalogoItem[]>([]); | ||||
|   const [mesas, setMesas] = useState<CatalogoItem[]>([]); | ||||
|  | ||||
|   // Estados para los valores seleccionados | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState(''); | ||||
|   const [selectedMunicipio, setSelectedMunicipio] = useState(''); | ||||
|   const [selectedCircuito, setSelectedCircuito] = useState(''); | ||||
|   const [selectedEstablecimiento, setSelectedEstablecimiento] = useState(''); | ||||
|   const [selectedMesa, setSelectedMesa] = useState(''); | ||||
|  | ||||
|   // Estados para la visualización del telegrama | ||||
|   const [telegrama, setTelegrama] = useState<TelegramaData | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   // Cargar secciones iniciales | ||||
|   useEffect(() => { | ||||
|     getSecciones().then(setSecciones); | ||||
|   }, []); | ||||
|  | ||||
|   // Cargar municipios cuando cambia la sección | ||||
|   useEffect(() => { | ||||
|     if (selectedSeccion) { | ||||
|       setMunicipios([]); setCircuitos([]); setEstablecimientos([]); setMesas([]); | ||||
|       setSelectedMunicipio(''); setSelectedCircuito(''); setSelectedEstablecimiento(''); setSelectedMesa(''); | ||||
|       getMunicipiosPorSeccion(selectedSeccion).then(setMunicipios); | ||||
|     } | ||||
|   }, [selectedSeccion]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedMunicipio) { | ||||
|       setCircuitos([]); setEstablecimientos([]); setMesas([]); | ||||
|       setSelectedCircuito(''); setSelectedEstablecimiento(''); setSelectedMesa(''); | ||||
|       getCircuitosPorMunicipio(selectedMunicipio).then(setCircuitos); | ||||
|     } | ||||
|   }, [selectedMunicipio]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedCircuito) { | ||||
|       setEstablecimientos([]); setMesas([]); | ||||
|       setSelectedEstablecimiento(''); setSelectedMesa(''); | ||||
|       getEstablecimientosPorCircuito(selectedCircuito).then(setEstablecimientos); | ||||
|     } | ||||
|   }, [selectedCircuito]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (selectedEstablecimiento) { | ||||
|       setMesas([]); | ||||
|       setSelectedMesa(''); | ||||
|       getMesasPorEstablecimiento(selectedEstablecimiento).then(setMesas); | ||||
|     } | ||||
|   }, [selectedEstablecimiento]); | ||||
|  | ||||
|   // Buscar el telegrama cuando se selecciona una mesa | ||||
|   useEffect(() => { | ||||
|     if (selectedMesa) { | ||||
|       setLoading(true); | ||||
|       setError(null); | ||||
|       setTelegrama(null); | ||||
|       getTelegramaPorId(selectedMesa) | ||||
|         .then(setTelegrama) | ||||
|         .catch(() => setError(`No se encontró el telegrama para la mesa seleccionada.`)) | ||||
|         .finally(() => setLoading(false)); | ||||
|     } | ||||
|   }, [selectedMesa]); | ||||
|          | ||||
|   return ( | ||||
|     <div className="telegrama-container"> | ||||
|       <h4>Consulta de Telegramas por Ubicación</h4> | ||||
|       <div className="filters-grid"> | ||||
|         <select value={selectedSeccion} onChange={e => setSelectedSeccion(e.target.value)}> | ||||
|           <option value="">1. Sección</option> | ||||
|           {secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)} | ||||
|         </select> | ||||
|         <select value={selectedMunicipio} onChange={e => setSelectedMunicipio(e.target.value)} disabled={!municipios.length}> | ||||
|           <option value="">2. Municipio</option> | ||||
|           {municipios.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)} | ||||
|         </select> | ||||
|         <select value={selectedCircuito} onChange={e => setSelectedCircuito(e.target.value)} disabled={!circuitos.length}> | ||||
|           <option value="">3. Circuito</option> | ||||
|           {circuitos.map(c => <option key={c.id} value={c.id}>{c.nombre}</option>)} | ||||
|         </select> | ||||
|         <select value={selectedEstablecimiento} onChange={e => setSelectedEstablecimiento(e.target.value)} disabled={!establecimientos.length}> | ||||
|           <option value="">4. Establecimiento</option> | ||||
|           {establecimientos.map(e => <option key={e.id} value={e.id}>{e.nombre}</option>)} | ||||
|         </select> | ||||
|         <select value={selectedMesa} onChange={e => setSelectedMesa(e.target.value)} disabled={!mesas.length}> | ||||
|           <option value="">5. Mesa</option> | ||||
|           {mesas.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)} | ||||
|         </select> | ||||
|       </div> | ||||
|  | ||||
|       <div className="telegrama-viewer"> | ||||
|         {loading && <div className="spinner"></div>} | ||||
|         {error && <p className="message error">{error}</p>} | ||||
|  | ||||
|         {telegrama && ( | ||||
|           <div className="telegrama-content"> | ||||
|             <div className="telegrama-pdf-viewer"> | ||||
|               <Document | ||||
|                 file={`data:application/pdf;base64,${telegrama.contenidoBase64}`} | ||||
|                 onLoadError={(error) => setError(`Error al cargar el PDF: ${error.message}`)} | ||||
|                 loading={<div className="spinner"></div>} | ||||
|               > | ||||
|                 <Page pageNumber={1} renderTextLayer={false} renderAnnotationLayer={false} /> | ||||
|               </Document> | ||||
|             </div> | ||||
|           </div> | ||||
|         )} | ||||
|  | ||||
|         {!loading && !telegrama && !error && <p className="message">Seleccione una mesa para visualizar el telegrama.</p>} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,112 +0,0 @@ | ||||
| /* src/components/TickerWidget.css */ | ||||
| .ticker-wrapper { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); | ||||
|     gap: 1.5rem; | ||||
|     width: 100%; | ||||
|     max-width: 1280px; | ||||
|     margin: 20px auto; | ||||
| } | ||||
| .ticker-card { | ||||
|     background-color: #ffffff; | ||||
|     border: 1px solid #e0e0e0; | ||||
|     box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|     padding: 15px 20px; | ||||
|     border-radius: 8px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
| } | ||||
|  | ||||
| .ticker-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   border-bottom: 1px solid #e0e0e0; /* Borde más claro */ | ||||
|   padding-bottom: 10px; | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
|  | ||||
| .ticker-header h3 { | ||||
|   margin: 0; | ||||
|   color: #212529; /* Color de título oscuro */ | ||||
|   font-size: 1.2em; | ||||
|   font-weight: 700; | ||||
| } | ||||
|  | ||||
| .ticker-stats { | ||||
|   display: flex; | ||||
|   gap: 20px; | ||||
|   font-size: 0.9em; | ||||
|   color: #555; | ||||
| } | ||||
|  | ||||
| .ticker-stats strong { | ||||
|   color: #0073e6; /* Se usa el azul primario para destacar */ | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .ticker-results { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 12px; /* Espacio entre partidos */ | ||||
| } | ||||
|  | ||||
| .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: 700; | ||||
| } | ||||
|  | ||||
| .party-bar-background { | ||||
|   background-color: #e9ecef; /* Fondo de barra claro */ | ||||
|   border-radius: 4px; | ||||
|   height: 10px; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .party-bar-foreground { | ||||
|   height: 100%; | ||||
|   border-radius: 4px; | ||||
|   transition: width 0.5s ease-in-out; | ||||
|   /* El color de fondo se sigue aplicando desde el componente, esto es correcto */ | ||||
| } | ||||
|  | ||||
| .ticker-results { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); /* Aumentamos el tamaño mínimo */ | ||||
|     gap: 20px; | ||||
| } | ||||
| .ticker-party { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 10px; /* Espacio entre logo y detalles */ | ||||
| } | ||||
| .party-logo { | ||||
|     flex-shrink: 0; | ||||
|     width: 75px; | ||||
|     height: 75px; | ||||
| } | ||||
| .party-logo img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: cover; | ||||
|     border-radius: 4px; | ||||
|     border: 1px solid #ddd; | ||||
| } | ||||
| .party-details { | ||||
|     flex-grow: 1; | ||||
|     min-width: 0; /* Previene que el flex item se desborde */ | ||||
| } | ||||
							
								
								
									
										87
									
								
								Elecciones-Web/frontend/src/components/common/DevApp.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,87 @@ | ||||
| // src/components/common/DevApp.tsx | ||||
| import { BancasWidget } from '../../features/legislativas/provinciales/BancasWidget' | ||||
| import { CongresoWidget } from '../../features/legislativas/provinciales/CongresoWidget' | ||||
| import MapaBsAs from '../../features/legislativas/provinciales/MapaBsAs' | ||||
| import { DipSenTickerWidget } from '../../features/legislativas/provinciales/DipSenTickerWidget' | ||||
| import { TelegramaWidget } from '../../features/legislativas/provinciales/TelegramaWidget' | ||||
| import { ConcejalesWidget } from '../../features/legislativas/provinciales/ConcejalesWidget' | ||||
| import MapaBsAsSecciones from '../../features/legislativas/provinciales/MapaBsAsSecciones' | ||||
| import { SenadoresWidget } from '../../features/legislativas/provinciales/SenadoresWidget' | ||||
| import { DiputadosWidget } from '../../features/legislativas/provinciales/DiputadosWidget' | ||||
| import { ResumenGeneralWidget } from '../../features/legislativas/provinciales/ResumenGeneralWidget' | ||||
| import { SenadoresTickerWidget } from '../../features/legislativas/provinciales/SenadoresTickerWidget' | ||||
| import { DiputadosTickerWidget } from '../../features/legislativas/provinciales/DiputadosTickerWidget' | ||||
| import { ConcejalesTickerWidget } from '../../features/legislativas/provinciales/ConcejalesTickerWidget' | ||||
| import { DiputadosPorSeccionWidget } from '../../features/legislativas/provinciales/DiputadosPorSeccionWidget' | ||||
| import { SenadoresPorSeccionWidget } from '../../features/legislativas/provinciales/SenadoresPorSeccionWidget' | ||||
| import { ConcejalesPorSeccionWidget } from '../../features/legislativas/provinciales/ConcejalesPorSeccionWidget' | ||||
| import { ResultadosTablaDetalladaWidget } from '../../features/legislativas/provinciales/ResultadosTablaDetalladaWidget' | ||||
| import { ResultadosRankingMunicipioWidget } from '../../features/legislativas/provinciales/ResultadosRankingMunicipioWidget' | ||||
| import '../../App.css'; | ||||
|  | ||||
|  | ||||
| export const DevApp = () => { | ||||
|   return ( | ||||
|     <> | ||||
|       <h1 style={{ textAlign: 'center', fontFamily: 'sans-serif' }}> | ||||
|         Showcase de Widgets - Elecciones 2025 | ||||
|       </h1> | ||||
|       <main> | ||||
|         <DipSenTickerWidget /> | ||||
|         <ResumenGeneralWidget /> | ||||
|         <SenadoresWidget /> | ||||
|         <DiputadosWidget /> | ||||
|         <ConcejalesWidget /> | ||||
|         <SenadoresTickerWidget /> | ||||
|         <DiputadosTickerWidget /> | ||||
|         <ConcejalesTickerWidget /> | ||||
|         <DiputadosPorSeccionWidget /> | ||||
|         <SenadoresPorSeccionWidget /> | ||||
|         <ConcejalesPorSeccionWidget /> | ||||
|         <CongresoWidget eleccionId={1} /> | ||||
|         <BancasWidget /> | ||||
|         <MapaBsAs /> | ||||
|         <MapaBsAsSecciones /> | ||||
|         <TelegramaWidget /> | ||||
|         <ResultadosTablaDetalladaWidget /> | ||||
|         <ResultadosRankingMunicipioWidget /> | ||||
|  | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|          | ||||
|         <h2>Mapa - Vista General (Por Defecto)</h2> | ||||
|         <p>Carga la vista provincial completa para Diputados.</p> | ||||
|         <MapaBsAs /> | ||||
|          | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|          | ||||
|         <h2>Mapa - Foco en La Plata (Diputados por defecto)</h2> | ||||
|         <p>Carga el mapa y automáticamente hace zoom en La Plata.</p> | ||||
|         <MapaBsAs focoMunicipio="LA PLATA" /> | ||||
|  | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|          | ||||
|         <h2>Mapa - Foco en Campana</h2> | ||||
|         <p>Carga el mapa y automáticamente hace zoom en Campana.</p> | ||||
|         <MapaBsAs focoMunicipio="CAMPANA" focoCategoria="senadores" /> | ||||
|          | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|  | ||||
|         <h2>Mapa - Vista General de Senadores</h2> | ||||
|         <p>Carga la vista provincial completa para la categoría Senadores.</p> | ||||
|         <MapaBsAs focoCategoria="senadores" /> | ||||
|  | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|          | ||||
|         <h2>Mapa - Foco en Bahía Blanca para Concejales</h2> | ||||
|         <p>Carga el mapa enfocado en Bahía Blanca y con la categoría Concejales seleccionada.</p> | ||||
|         <MapaBsAs focoMunicipio="BAHIA BLANCA" focoCategoria="concejales" /> | ||||
|  | ||||
|         <hr className="border-gray-300 my-8" /> | ||||
|  | ||||
|         <h2>Mapa - Foco Inválido (La Plata para Senadores)</h2> | ||||
|         <p>Debería mostrar la vista provincial de Senadores y un warning en la consola del navegador.</p> | ||||
|         <MapaBsAs focoMunicipio="LA PLATA" focoCategoria="senadores" /> | ||||
|       </main> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,339 @@ | ||||
| // src/components/common/DiputadosNacionalesLayout.tsx | ||||
| import React from 'react'; | ||||
| import type { PartidoComposicionNacional } from '../../apiService'; | ||||
|  | ||||
| // --- Interfaces Actualizadas --- | ||||
| interface DiputadosNacionalesLayoutProps { | ||||
|   partyData: PartidoComposicionNacional[]; | ||||
|   size?: number; | ||||
|   presidenteBancada?: { color: string | null } | null; // <-- Nueva Prop | ||||
| } | ||||
|  | ||||
| const PRESIDENTE_SEAT_INDEX = 0; // El escaño 'seat-0' es el del presidente | ||||
|  | ||||
| export const DiputadosNacionalesLayout: React.FC<DiputadosNacionalesLayoutProps> = ({ | ||||
|   partyData, | ||||
|   size = 800, | ||||
|   presidenteBancada, // <-- Recibimos la nueva prop | ||||
| }) => { | ||||
|   // --- ARRAY DE 257 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" --- | ||||
|   const seatElements = [ | ||||
|     <circle key="seat-0" id="seat-0" r="15.7" cy="639.5" cx="595.3" />, | ||||
|     <circle key="seat-1" id="seat-1" r="15.7" cy="673.1" cx="109.3" />, | ||||
|     <circle key="seat-2" id="seat-2" r="15.7" cy="673.1" cx="161.7" />, | ||||
|     <circle key="seat-3" id="seat-3" r="15.7" cy="673.5" cx="214.3" />, | ||||
|     <circle key="seat-4" id="seat-4" r="15.7" cy="673.2" cx="266.5" />, | ||||
|     <circle key="seat-5" id="seat-5" r="15.7" cy="669.5" cx="319.4" />, | ||||
|     <circle key="seat-6" id="seat-6" r="15.7" cy="660" cx="370.8" />, | ||||
|     <circle key="seat-7" id="seat-7" transform="rotate(-88.1)" r="15.7" cy="77.69" cx="-634.1" />, | ||||
|     <circle key="seat-8" id="seat-8" r="15.7" cy="639" cx="109.3" />, | ||||
|     <circle key="seat-9" id="seat-9" r="15.7" cy="639" cx="161.7" />, | ||||
|     <circle key="seat-10" id="seat-10" r="15.7" cy="639.2" cx="214.3" />, | ||||
|     <circle key="seat-11" id="seat-11" r="15.7" cy="638.8" cx="266.7" />, | ||||
|     <circle key="seat-12" id="seat-12" r="15.7" cy="635.1" cx="319.4" />, | ||||
|     <circle key="seat-13" id="seat-13" r="15.7" cy="625.7" cx="371.7" />, | ||||
|     <circle key="seat-14" id="seat-14" r="15.7" cy="639" cx="424.2" />, | ||||
|     <circle key="seat-15" id="seat-15" transform="rotate(-88.1)" r="15.7" cy="77" cx="-600.18" />, | ||||
|     <circle key="seat-16" id="seat-16" r="15.7" cy="600.9" cx="109.5" />, | ||||
|     <circle key="seat-17" id="seat-17" r="15.7" cy="603.7" cx="162.1" />, | ||||
|     <circle key="seat-18" id="seat-18" r="15.7" cy="598.6" cx="215" />, | ||||
|     <circle key="seat-19" id="seat-19" r="15.7" cy="602.6" cx="267.1" />, | ||||
|     <circle key="seat-20" id="seat-20" transform="rotate(-88.1)" r="15.7" cy="76.57" cx="-562.57" />, | ||||
|     <circle key="seat-21" id="seat-21" r="15.7" cy="566.7" cx="112.2" />, | ||||
|     <circle key="seat-22" id="seat-22" r="15.7" cy="570" cx="164.7" />, | ||||
|     <circle key="seat-23" id="seat-23" r="15.7" cy="564.5" cx="218.2" />, | ||||
|     <circle key="seat-24" id="seat-24" r="15.7" cy="568.6" cx="270.9" />, | ||||
|     <circle key="seat-25" id="seat-25" r="15.7" cy="588" cx="321.1" />, | ||||
|     <circle key="seat-26" id="seat-26" transform="rotate(-88.1)" r="15.7" cy="79.88" cx="-524.51" />, | ||||
|     <circle key="seat-27" id="seat-27" transform="rotate(-5.7)" r="15.7" cy="539.19" cx="65.05" />, | ||||
|     <circle key="seat-28" id="seat-28" r="15.7" cy="535.9" cx="170" />, | ||||
|     <circle key="seat-29" id="seat-29" transform="rotate(-88.1)" r="15.7" cy="86.87" cx="-488.2" />, | ||||
|     <circle key="seat-30" id="seat-30" r="15.7" cy="497.2" cx="125.2" />, | ||||
|     <circle key="seat-31" id="seat-31" r="15.7" cy="502.8" cx="178.2" />, | ||||
|     <circle key="seat-32" id="seat-32" r="15.7" cy="525.1" cx="226.3" />, | ||||
|     <circle key="seat-33" id="seat-33" r="15.7" cy="533.1" cx="278.4" />, | ||||
|     <circle key="seat-34" id="seat-34" r="15.7" cy="554.6" cx="327.1" />, | ||||
|     <circle key="seat-35" id="seat-35" r="15.7" cy="567.9" cx="377.9" />, | ||||
|     <circle key="seat-36" id="seat-36" r="15.7" cy="596.7" cx="426" />, | ||||
|     <circle key="seat-37" id="seat-37" r="15.7" cy="453.8" cx="79.7" />, | ||||
|     <circle key="seat-38" id="seat-38" r="15.7" cy="462" cx="135.7" />, | ||||
|     <circle key="seat-39" id="seat-39" r="15.7" cy="469.3" cx="188.9" />, | ||||
|     <circle key="seat-40" id="seat-40" r="15.7" cy="492.6" cx="236.4" />, | ||||
|     <circle key="seat-41" id="seat-41" r="15.7" cy="500.6" cx="289.8" />, | ||||
|     <circle key="seat-42" id="seat-42" r="15.7" cy="511.6" cx="341.5" />, | ||||
|     <circle key="seat-43" id="seat-43" r="15.7" cy="535" cx="388.9" />, | ||||
|     <circle key="seat-44" id="seat-44" r="15.7" cy="555" cx="437.3" />, | ||||
|     <circle key="seat-45" id="seat-45" r="15.7" cy="419.3" cx="92.8" />, | ||||
|     <circle key="seat-46" id="seat-46" r="15.7" cy="429.8" cx="148.1" />, | ||||
|     <circle key="seat-47" id="seat-47" r="15.7" cy="387.4" cx="106.8" />, | ||||
|     <circle key="seat-48" id="seat-48" transform="rotate(-5.7)" r="15.7" cy="364.72" cx="89.86" />, | ||||
|     <circle key="seat-49" id="seat-49" r="15.7" cy="395.5" cx="164.4" />, | ||||
|     <circle key="seat-50" id="seat-50" r="15.7" cy="437.3" cx="202.4" />, | ||||
|     <circle key="seat-51" id="seat-51" r="15.7" cy="455.4" cx="252.1" />, | ||||
|     <circle key="seat-52" id="seat-52" r="15.7" cy="325.1" cx="144.9" />, | ||||
|     <circle key="seat-53" id="seat-53" r="15.7" cy="365.7" cx="181.3" />, | ||||
|     <circle key="seat-54" id="seat-54" r="15.7" cy="405.1" cx="218.8" />, | ||||
|     <circle key="seat-55" id="seat-55" r="15.7" cy="425.6" cx="267.7" />, | ||||
|     <circle key="seat-56" id="seat-56" r="15.7" cy="464.9" cx="306.5" />, | ||||
|     <circle key="seat-57" id="seat-57" r="15.7" cy="292.1" cx="168.7" />, | ||||
|     <circle key="seat-58" id="seat-58" r="15.7" cy="334.6" cx="202.3" />, | ||||
|     <circle key="seat-59" id="seat-59" r="15.7" cy="376.9" cx="236.7" />, | ||||
|     <circle key="seat-60" id="seat-60" r="15.7" cy="265.1" cx="190.8" />, | ||||
|     <circle key="seat-61" id="seat-61" r="15.7" cy="307.2" cx="224" />, | ||||
|     <circle key="seat-62" id="seat-62" r="15.7" cy="346.9" cx="259.3" />, | ||||
|     <circle key="seat-63" id="seat-63" r="15.7" cy="393" cx="289.6" />, | ||||
|     <circle key="seat-64" id="seat-64" r="15.7" cy="435.9" cx="323.7" />, | ||||
|     <circle key="seat-65" id="seat-65" r="15.7" cy="480.8" cx="357.3" />, | ||||
|     <circle key="seat-66" id="seat-66" r="15.7" cy="236.2" cx="218.1" />, | ||||
|     <circle key="seat-67" id="seat-67" r="15.7" cy="278.6" cx="250" />, | ||||
|     <circle key="seat-68" id="seat-68" r="15.7" cy="320.2" cx="283" />, | ||||
|     <circle key="seat-69" id="seat-69" r="15.7" cy="362" cx="315.5" />, | ||||
|     <circle key="seat-70" id="seat-70" r="15.7" cy="403.8" cx="348.7" />, | ||||
|     <circle key="seat-71" id="seat-71" r="15.7" cy="445.9" cx="381.6" />, | ||||
|     <circle key="seat-72" id="seat-72" r="15.7" cy="489" cx="415.1" />, | ||||
|     <circle key="seat-73" id="seat-73" r="15.7" cy="515.6" cx="460.7" />, | ||||
|     <circle key="seat-74" id="seat-74" r="15.7" cy="485.2" cx="491" />, | ||||
|     <circle key="seat-75" id="seat-75" r="15.7" cy="213.6" cx="243.2" />, | ||||
|     <circle key="seat-76" id="seat-76" r="15.7" cy="254.9" cx="275.3" />, | ||||
|     <circle key="seat-77" id="seat-77" r="15.7" cy="296.4" cx="307.8" />, | ||||
|     <circle key="seat-78" id="seat-78" r="15.7" cy="337.6" cx="339.9" />, | ||||
|     <circle key="seat-79" id="seat-79" r="15.7" cy="379" cx="372.5" />, | ||||
|     <circle key="seat-80" id="seat-80" r="15.7" cy="420.8" cx="405.1" />, | ||||
|     <circle key="seat-81" id="seat-81" r="15.7" cy="462.7" cx="437.2" />, | ||||
|     <circle key="seat-82" id="seat-82" r="15.5" cy="181.8" cx="283.1" />, | ||||
|     <circle key="seat-83" id="seat-83" r="15.5" cy="223.6" cx="315.4" />, | ||||
|     <circle key="seat-84" id="seat-84" r="15.7" cy="262.6" cx="351" />, | ||||
|     <circle key="seat-85" id="seat-85" r="15.5" cy="304.5" cx="382.7" />, | ||||
|     <circle key="seat-86" id="seat-86" r="15.7" cy="339.1" cx="425.3" />, | ||||
|     <circle key="seat-87" id="seat-87" r="15.7" cy="379" cx="461" />, | ||||
|     <circle key="seat-88" id="seat-88" r="15.7" cy="420.4" cx="495.9" />, | ||||
|     <circle key="seat-89" id="seat-89" r="15.7" cy="463.5" cx="528.1" />, | ||||
|     <circle key="seat-90" id="seat-90" r="15.5" cy="160.4" cx="315.7" />, | ||||
|     <circle key="seat-91" id="seat-91" r="15.5" cy="206.2" cx="342.9" />, | ||||
|     <circle key="seat-92" id="seat-92" r="15.7" cy="245.1" cx="379" />, | ||||
|     <circle key="seat-93" id="seat-93" r="15.5" cy="287.4" cx="410.5" />, | ||||
|     <circle key="seat-94" id="seat-94" r="15.7" cy="323.4" cx="455.9" />, | ||||
|     <circle key="seat-95" id="seat-95" transform="rotate(-80.8)" r="15.7" cy="555.93" cx="-274.27" />, | ||||
|     <circle key="seat-96" id="seat-96" r="15.7" cy="407.6" cx="527.7" />, | ||||
|     <circle key="seat-97" id="seat-97" r="15.5" cy="142.7" cx="345.9" />, | ||||
|     <circle key="seat-98" id="seat-98" r="15.5" cy="186.8" cx="375.8" />, | ||||
|     <circle key="seat-99" id="seat-99" r="15.5" cy="125.9" cx="377.8" />, | ||||
|     <circle key="seat-100" id="seat-100" r="15.5" cy="173.7" cx="405.1" />, | ||||
|     <circle key="seat-101" id="seat-101" r="15.7" cy="223" cx="422.9" />, | ||||
|     <circle key="seat-102" id="seat-102" r="15.5" cy="270.9" cx="444.3" />, | ||||
|     <circle key="seat-103" id="seat-103" r="15.5" cy="112" cx="409.4" />, | ||||
|     <circle key="seat-104" id="seat-104" r="15.5" cy="157.7" cx="438.1" />, | ||||
|     <circle key="seat-105" id="seat-105" r="15.7" cy="209" cx="453.9" />, | ||||
|     <circle key="seat-106" id="seat-106" r="15.5" cy="259.6" cx="474.2" />, | ||||
|     <circle key="seat-107" id="seat-107" r="15.7" cy="306.3" cx="499.3" />, | ||||
|     <circle key="seat-108" id="seat-108" r="15.5" cy="100.1" cx="443.4" />, | ||||
|     <circle key="seat-109" id="seat-109" r="15.5" cy="146.7" cx="472.7" />, | ||||
|     <circle key="seat-110" id="seat-110" r="15.7" cy="197.9" cx="497" />, | ||||
|     <circle key="seat-111" id="seat-111" r="15.5" cy="249" cx="508.8" />, | ||||
|     <circle key="seat-112" id="seat-112" r="15.7" cy="298.4" cx="532.7" />, | ||||
|     <circle key="seat-113" id="seat-113" r="15.7" cy="350.8" cx="538.1" />, | ||||
|     <circle key="seat-114" id="seat-114" r="15.5" cy="92.2" cx="477" />, | ||||
|     <circle key="seat-115" id="seat-115" r="15.5" cy="84.4" cx="510" />, | ||||
|     <circle key="seat-116" id="seat-116" transform="rotate(-80.8)" r="15.5" cy="523.04" cx="-55.62" />, | ||||
|     <circle key="seat-117" id="seat-117" r="15.7" cy="190.1" cx="531.6" />, | ||||
|     <circle key="seat-118" id="seat-118" r="15.5" cy="243.4" cx="542.3" />, | ||||
|     <circle key="seat-119" id="seat-119" r="15.5" cy="80.7" cx="544.3" />, | ||||
|     <circle key="seat-120" id="seat-120" r="15.5" cy="136.1" cx="541.9" />, | ||||
|     <circle key="seat-121" id="seat-121" r="15.5" cy="78.5" cx="579" />, | ||||
|     <circle key="seat-122" id="seat-122" r="15.5" cy="135" cx="578.2" />, | ||||
|     <circle key="seat-123" id="seat-123" r="15.7" cy="187.6" cx="577.9" />, | ||||
|     <circle key="seat-124" id="seat-124" r="15.5" cy="240" cx="579" />, | ||||
|     <circle key="seat-125" id="seat-125" r="15.7" cy="292.6" cx="578" />, | ||||
|     <circle key="seat-126" id="seat-126" r="15.7" cy="345.3" cx="578" />, | ||||
|     <circle key="seat-127" id="seat-127" r="15.7" cy="398" cx="577.8" />, | ||||
|     <circle key="seat-128" id="seat-128" r="15.7" cy="451.2" cx="572.2" />, | ||||
|     <circle key="seat-129" id="seat-129" r="15.5" cy="78.5" cx="613.5" />, | ||||
|     <circle key="seat-130" id="seat-130" r="15.5" cy="135" cx="612.3" />, | ||||
|     <circle key="seat-131" id="seat-131" r="15.7" cy="187.6" cx="612.6" />, | ||||
|     <circle key="seat-132" id="seat-132" r="15.5" cy="240" cx="611.5" />, | ||||
|     <circle key="seat-133" id="seat-133" r="15.7" cy="292.6" cx="612.5" />, | ||||
|     <circle key="seat-134" id="seat-134" r="15.7" cy="345.3" cx="612.5" />, | ||||
|     <circle key="seat-135" id="seat-135" r="15.7" cy="398" cx="612.7" />, | ||||
|     <circle key="seat-136" id="seat-136" r="15.7" cy="451.2" cx="618.3" />, | ||||
|     <circle key="seat-137" id="seat-137" r="15.5" cy="82.6" cx="646.3" />, | ||||
|     <circle key="seat-138" id="seat-138" r="15.5" cy="86.4" cx="680.5" />, | ||||
|     <circle key="seat-139" id="seat-139" r="15.5" cy="138.4" cx="650.6" />, | ||||
|     <circle key="seat-140" id="seat-140" r="15.5" cy="94.2" cx="715.6" />, | ||||
|     <circle key="seat-141" id="seat-141" r="15.5" cy="142.6" cx="685.4" />, | ||||
|     <circle key="seat-142" id="seat-142" r="15.7" cy="190.1" cx="657" />, | ||||
|     <circle key="seat-143" id="seat-143" r="15.5" cy="243.4" cx="648.3" />, | ||||
|     <circle key="seat-144" id="seat-144" r="15.5" cy="104.1" cx="747.1" />, | ||||
|     <circle key="seat-145" id="seat-145" r="15.5" cy="150.7" cx="719.9" />, | ||||
|     <circle key="seat-146" id="seat-146" r="15.7" cy="197.9" cx="691.5" />, | ||||
|     <circle key="seat-147" id="seat-147" r="15.5" cy="248.5" cx="679.8" />, | ||||
|     <circle key="seat-148" id="seat-148" r="15.7" cy="298.4" cx="657.8" />, | ||||
|     <circle key="seat-149" id="seat-149" r="15.7" cy="350.8" cx="652.4" />, | ||||
|     <circle key="seat-150" id="seat-150" r="15.5" cy="116" cx="783.1" />, | ||||
|     <circle key="seat-151" id="seat-151" r="15.5" cy="159.7" cx="750.4" />, | ||||
|     <circle key="seat-152" id="seat-152" r="15.7" cy="211" cx="736.6" />, | ||||
|     <circle key="seat-153" id="seat-153" r="15.5" cy="259.6" cx="716.4" />, | ||||
|     <circle key="seat-154" id="seat-154" r="15.7" cy="306.3" cx="691.2" />, | ||||
|     <circle key="seat-155" id="seat-155" r="15.5" cy="127.9" cx="812.8" />, | ||||
|     <circle key="seat-156" id="seat-156" r="15.5" cy="173.7" cx="785.5" />, | ||||
|     <circle key="seat-157" id="seat-157" r="15.7" cy="223" cx="767.7" />, | ||||
|     <circle key="seat-158" id="seat-158" r="15.5" cy="270.9" cx="746.3" />, | ||||
|     <circle key="seat-159" id="seat-159" r="15.5" cy="144.7" cx="846.6" />, | ||||
|     <circle key="seat-160" id="seat-160" r="15.5" cy="186.8" cx="814.8" />, | ||||
|     <circle key="seat-161" id="seat-161" r="15.5" cy="160.4" cx="874.8" />, | ||||
|     <circle key="seat-162" id="seat-162" r="15.5" cy="206.2" cx="847.6" />, | ||||
|     <circle key="seat-163" id="seat-163" r="15.7" cy="245.1" cx="811.5" />, | ||||
|     <circle key="seat-164" id="seat-164" r="15.5" cy="287.4" cx="780.1" />, | ||||
|     <circle key="seat-165" id="seat-165" r="15.7" cy="323.4" cx="734.6" />, | ||||
|     <circle key="seat-166" id="seat-166" r="15.7" cy="357.8" cx="687.4" />, | ||||
|     <circle key="seat-167" id="seat-167" r="15.7" cy="407.6" cx="662.8" />, | ||||
|     <circle key="seat-168" id="seat-168" r="15.5" cy="181.8" cx="907.5" />, | ||||
|     <circle key="seat-169" id="seat-169" r="15.5" cy="223.6" cx="875.2" />, | ||||
|     <circle key="seat-170" id="seat-170" r="15.7" cy="262.6" cx="839.5" />, | ||||
|     <circle key="seat-171" id="seat-171" r="15.5" cy="304.3" cx="807.8" />, | ||||
|     <circle key="seat-172" id="seat-172" r="15.7" cy="339.1" cx="765.3" />, | ||||
|     <circle key="seat-173" id="seat-173" r="15.7" cy="379" cx="729.6" />, | ||||
|     <circle key="seat-174" id="seat-174" r="15.7" cy="420.4" cx="694.6" />, | ||||
|     <circle key="seat-175" id="seat-175" r="15.7" cy="463.5" cx="662.5" />, | ||||
|     <circle key="seat-176" id="seat-176" r="15.7" cy="485.4" cx="699.5" />, | ||||
|     <circle key="seat-177" id="seat-177" r="15.7" cy="213.6" cx="947.4" />, | ||||
|     <circle key="seat-178" id="seat-178" r="15.7" cy="254.9" cx="915.2" />, | ||||
|     <circle key="seat-179" id="seat-179" r="15.7" cy="296.4" cx="882.7" />, | ||||
|     <circle key="seat-180" id="seat-180" r="15.7" cy="337.6" cx="850.7" />, | ||||
|     <circle key="seat-181" id="seat-181" r="15.7" cy="379" cx="818.1" />, | ||||
|     <circle key="seat-182" id="seat-182" r="15.7" cy="420.8" cx="785.4" />, | ||||
|     <circle key="seat-183" id="seat-183" r="15.7" cy="462.7" cx="753.4" />, | ||||
|     <circle key="seat-184" id="seat-184" r="15.7" cy="515.4" cx="730.1" />, | ||||
|     <circle key="seat-185" id="seat-185" r="15.7" cy="236.2" cx="972.4" />, | ||||
|     <circle key="seat-186" id="seat-186" r="15.7" cy="278.6" cx="940.5" />, | ||||
|     <circle key="seat-187" id="seat-187" r="15.7" cy="320.2" cx="907.5" />, | ||||
|     <circle key="seat-188" id="seat-188" r="15.7" cy="362" cx="875.1" />, | ||||
|     <circle key="seat-189" id="seat-189" r="15.7" cy="403.8" cx="841.8" />, | ||||
|     <circle key="seat-190" id="seat-190" r="15.7" cy="445.9" cx="808.9" />, | ||||
|     <circle key="seat-191" id="seat-191" r="15.7" cy="489" cx="775.5" />, | ||||
|     <circle key="seat-192" id="seat-192" r="15.7" cy="265.1" cx="999.7" />, | ||||
|     <circle key="seat-193" id="seat-193" r="15.7" cy="307.2" cx="966.6" />, | ||||
|     <circle key="seat-194" id="seat-194" r="15.7" cy="346.9" cx="931.2" />, | ||||
|     <circle key="seat-195" id="seat-195" r="15.7" cy="393" cx="901" />, | ||||
|     <circle key="seat-196" id="seat-196" r="15.7" cy="435.9" cx="866.9" />, | ||||
|     <circle key="seat-197" id="seat-197" r="15.7" cy="480.8" cx="833.2" />, | ||||
|     <circle key="seat-198" id="seat-198" transform="rotate(-80.8)" r="15.7" cy="1055.16" cx="-124.85" />, | ||||
|     <circle key="seat-199" id="seat-199" r="15.7" cy="334.6" cx="988.2" />, | ||||
|     <circle key="seat-200" id="seat-200" r="15.7" cy="376.9" cx="953.8" />, | ||||
|     <circle key="seat-201" id="seat-201" r="15.7" cy="425.6" cx="922.8" />, | ||||
|     <circle key="seat-202" id="seat-202" r="15.7" cy="464.9" cx="884" />, | ||||
|     <circle key="seat-203" id="seat-203" r="15.7" cy="325.1" cx="1045.7" />, | ||||
|     <circle key="seat-204" id="seat-204" r="15.7" cy="365.7" cx="1009.2" />, | ||||
|     <circle key="seat-205" id="seat-205" r="15.7" cy="405.1" cx="971.7" />, | ||||
|     <circle key="seat-206" id="seat-206" r="15.7" cy="354.1" cx="1063.2" />, | ||||
|     <circle key="seat-207" id="seat-207" transform="rotate(-80.8)" r="15.7" cy="1075.78" cx="-226.25" />, | ||||
|     <circle key="seat-208" id="seat-208" r="15.7" cy="387.4" cx="1081.8" />, | ||||
|     <circle key="seat-209" id="seat-209" r="15.7" cy="421.3" cx="1095.7" />, | ||||
|     <circle key="seat-210" id="seat-210" r="15.7" cy="429.8" cx="1042.5" />, | ||||
|     <circle key="seat-211" id="seat-211" r="15.7" cy="437.3" cx="988.2" />, | ||||
|     <circle key="seat-212" id="seat-212" r="15.7" cy="455.4" cx="938.5" />, | ||||
|     <circle key="seat-213" id="seat-213" r="15.7" cy="455.8" cx="1108.8" />, | ||||
|     <circle key="seat-214" id="seat-214" r="15.7" cy="462" cx="1054.9" />, | ||||
|     <circle key="seat-215" id="seat-215" r="15.7" cy="469.3" cx="1001.6" />, | ||||
|     <circle key="seat-216" id="seat-216" r="15.7" cy="492.6" cx="954.1" />, | ||||
|     <circle key="seat-217" id="seat-217" r="15.7" cy="500.6" cx="900.8" />, | ||||
|     <circle key="seat-218" id="seat-218" r="15.7" cy="511.6" cx="849" />, | ||||
|     <circle key="seat-219" id="seat-219" r="15.7" cy="535" cx="801.6" />, | ||||
|     <circle key="seat-220" id="seat-220" r="15.7" cy="554.8" cx="753.3" />, | ||||
|     <circle key="seat-221" id="seat-221" r="15.7" cy="490.9" cx="1118" />, | ||||
|     <circle key="seat-222" id="seat-222" r="15.7" cy="497.2" cx="1065.3" />, | ||||
|     <circle key="seat-223" id="seat-223" r="15.7" cy="502.8" cx="1012.3" />, | ||||
|     <circle key="seat-224" id="seat-224" r="15.7" cy="525.1" cx="964.2" />, | ||||
|     <circle key="seat-225" id="seat-225" r="15.7" cy="533.1" cx="912.2" />, | ||||
|     <circle key="seat-226" id="seat-226" r="15.7" cy="554.6" cx="863.4" />, | ||||
|     <circle key="seat-227" id="seat-227" r="15.7" cy="567.9" cx="812.7" />, | ||||
|     <circle key="seat-228" id="seat-228" r="15.7" cy="596.7" cx="764.8" />, | ||||
|     <circle key="seat-229" id="seat-229" r="15.7" cy="528.9" cx="1126.1" />, | ||||
|     <circle key="seat-230" id="seat-230" r="15.7" cy="530.2" cx="1072.7" />, | ||||
|     <circle key="seat-231" id="seat-231" transform="rotate(-80.8)" r="15.7" cy="1092.81" cx="-365.69" />, | ||||
|     <circle key="seat-232" id="seat-232" r="15.7" cy="562.9" cx="1130.6" />, | ||||
|     <circle key="seat-233" id="seat-233" r="15.7" cy="566.7" cx="1078.3" />, | ||||
|     <circle key="seat-234" id="seat-234" transform="rotate(-80.8)" r="15.7" cy="1103.39" cx="-398.54" />, | ||||
|     <circle key="seat-235" id="seat-235" r="15.7" cy="564.5" cx="972.4" />, | ||||
|     <circle key="seat-236" id="seat-236" r="15.7" cy="568.6" cx="919.7" />, | ||||
|     <circle key="seat-237" id="seat-237" r="15.7" cy="588" cx="869.4" />, | ||||
|     <circle key="seat-238" id="seat-238" r="15.7" cy="602.5" cx="1133.5" />, | ||||
|     <circle key="seat-239" id="seat-239" r="15.7" cy="600.9" cx="1081" />, | ||||
|     <circle key="seat-240" id="seat-240" transform="rotate(-80.8)" r="15.7" cy="1111.41" cx="-431.3" />, | ||||
|     <circle key="seat-241" id="seat-241" r="15.7" cy="598.6" cx="975.6" />, | ||||
|     <circle key="seat-242" id="seat-242" r="15.7" cy="602.6" cx="923.4" />, | ||||
|     <circle key="seat-243" id="seat-243" r="15.7" cy="636.4" cx="1133.9" />, | ||||
|     <circle key="seat-244" id="seat-244" r="15.7" cy="639" cx="1081.3" />, | ||||
|     <circle key="seat-245" id="seat-245" transform="rotate(-80.8)" r="15.7" cy="1117.48" cx="-466.13" />, | ||||
|     <circle key="seat-246" id="seat-246" r="15.7" cy="639.2" cx="976.3" />, | ||||
|     <circle key="seat-247" id="seat-247" r="15.7" cy="638.8" cx="923.9" />, | ||||
|     <circle key="seat-248" id="seat-248" r="15.7" cy="635.1" cx="871.2" />, | ||||
|     <circle key="seat-249" id="seat-249" r="15.7" cy="625.7" cx="818.8" />, | ||||
|     <circle key="seat-250" id="seat-250" r="15.7" cy="639" cx="766.3" />, | ||||
|     <circle key="seat-251" id="seat-251" r="15.7" cy="673.1" cx="1081.3" />, | ||||
|     <circle key="seat-252" id="seat-252" transform="rotate(-80.8)" r="15.7" cy="1122.99" cx="-499.74" />, | ||||
|     <circle key="seat-253" id="seat-253" r="15.7" cy="673.5" cx="976.3" />, | ||||
|     <circle key="seat-254" id="seat-254" r="15.7" cy="673.2" cx="924" />, | ||||
|     <circle key="seat-255" id="seat-255" r="15.7" cy="669.5" cx="871.2" />, | ||||
|     <circle key="seat-256" id="seat-256" r="15.7" cy="660" cx="819.7" />, | ||||
|   ]; | ||||
|  | ||||
|   let seatIndex = 1; // Empezamos a contar desde 1, ya que el 0 es presidencial | ||||
|  | ||||
|   return ( | ||||
|     <svg viewBox="0 0 1190.6 772.2" width={size} height={size * (772.2 / 1190.6)} style={{ display: 'block', margin: 'auto' }}> | ||||
|       <g> | ||||
|         {/* Renderizamos el escaño presidencial primero y por separado */} | ||||
|         {presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], { | ||||
|           fill: presidenteBancada.color || '#A9A9A9', | ||||
|           strokeWidth: 0.5, | ||||
|         })} | ||||
|         {partyData.map(partido => { | ||||
|           // Por cada partido, creamos un array combinado de sus escaños | ||||
|           const partySeats = [ | ||||
|             ...Array(partido.bancasFijos).fill({ isNew: false }), | ||||
|             ...Array(partido.bancasGanadas).fill({ isNew: true }) | ||||
|           ]; | ||||
|  | ||||
|           return ( | ||||
|             // Envolvemos todos los escaños de un partido en un <g> | ||||
|             <g | ||||
|               key={partido.id} | ||||
|               className="party-block" | ||||
|               data-tooltip-id="party-tooltip" | ||||
|               data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`} | ||||
|             > | ||||
|               {partySeats.map((seatInfo, i) => { | ||||
|                 // Si ya no hay más plantillas de escaños, no renderizamos nada | ||||
|                 if (seatIndex >= seatElements.length) return null; | ||||
|  | ||||
|                 const template = seatElements[seatIndex]; | ||||
|                 seatIndex++; // Incrementamos el contador para el siguiente escaño | ||||
|  | ||||
|                 // Clonamos la plantilla con el estilo apropiado | ||||
|                 return React.cloneElement(template, { | ||||
|                   key: `${partido.id}-${i}`, | ||||
|                   className: 'seat-circle', | ||||
|                   fill: partido.color || '#808080', | ||||
|                   fillOpacity: seatInfo.isNew ? 1 : 0.3, // Opacidad para bancas previas | ||||
|                   stroke: partido.color || '#808080', | ||||
|                   strokeWidth: 0.5, | ||||
|                 }); | ||||
|               })} | ||||
|             </g> | ||||
|           ); | ||||
|         })} | ||||
|         {/* Renderizamos los escaños vacíos sobrantes */} | ||||
|         {seatIndex < seatElements.length && | ||||
|           seatElements.slice(seatIndex).map((template, i) => | ||||
|             React.cloneElement(template, { | ||||
|               key: `empty-${i}`, | ||||
|               fill: '#E0E0E0', | ||||
|               stroke: '#ffffff', | ||||
|               strokeWidth: 0.5 | ||||
|             }) | ||||
|           ) | ||||
|         } | ||||
|       </g> | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,4 +1,4 @@ | ||||
| // src/components/ImageWithFallback.tsx
 | ||||
| // src/components/common/ImageWithFallback.tsx
 | ||||
| import { useState, useEffect } from 'react'; | ||||
| 
 | ||||
| interface Props extends React.ImgHTMLAttributes<HTMLImageElement> { | ||||
| @@ -1,5 +1,6 @@ | ||||
| // src/components/ParliamentLayout.tsx
 | ||||
| // src/components/common/ParliamentLayout.tsx
 | ||||
| import React, { useLayoutEffect } from 'react'; | ||||
| import { assetBaseUrl } from '../../apiService'; | ||||
| import { handleImageFallback } from './imageFallback'; | ||||
| 
 | ||||
| // Interfaces (no cambian)
 | ||||
| @@ -28,7 +29,7 @@ export const ParliamentLayout: React.FC<ParliamentLayoutProps> = ({ | ||||
|   // HOOK DE IMAGENES POR DEFECTO
 | ||||
|   useLayoutEffect(() => { | ||||
|     // Se ejecuta después de que el componente y el tooltip se hayan renderizado
 | ||||
|     handleImageFallback('.seat-tooltip img', '/default-avatar.png'); | ||||
|     handleImageFallback('.seat-tooltip img', `${assetBaseUrl}/default-avatar.png`); | ||||
|   }, [seatData, presidenteBancada]); // Dependencias: se vuelve a ejecutar si estos datos cambian
 | ||||
|   const uniqueColors = [...new Set(seatData.map(d => d.color))]; | ||||
| 
 | ||||
| @@ -165,7 +166,7 @@ export const ParliamentLayout: React.FC<ParliamentLayoutProps> = ({ | ||||
|       strokeWidth: 1.5, | ||||
|       'data-tooltip-id': seat.ocupante ? 'seat-tooltip' : undefined, | ||||
|       'data-tooltip-html': seat.ocupante | ||||
|         ? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || '/default-avatar.png'}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>` | ||||
|         ? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || `${assetBaseUrl}/default-avatar.png`}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>` | ||||
|         : undefined, | ||||
|     }); | ||||
|   }); | ||||
| @@ -0,0 +1,154 @@ | ||||
| // src/components/common/SenadoresNacionalesLayout.tsx | ||||
| import React from 'react'; | ||||
| import type { PartidoComposicionNacional } from '../../apiService'; | ||||
|  | ||||
| // Interfaces | ||||
| interface SenadoresNacionalesLayoutProps { | ||||
|   partyData: PartidoComposicionNacional[]; | ||||
|   size?: number; | ||||
|   presidenteBancada?: { color: string | null } | null; | ||||
| } | ||||
|  | ||||
| const PRESIDENTE_SEAT_INDEX = 0; | ||||
|  | ||||
| export const SenadoresNacionalesLayout: React.FC<SenadoresNacionalesLayoutProps> = ({ | ||||
|   partyData, | ||||
|   size = 800, | ||||
|   presidenteBancada, | ||||
| }) => { | ||||
|   // --- ARRAY DE 73 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" --- | ||||
|   // El asiento 0 es el presidencial, los 72 restantes son los senadores. | ||||
|   const seatElements = [ | ||||
|     <circle key="seat-0" id="seat-0" r="7.1" cy="187" cx="168.6" />, | ||||
|     <circle key="seat-1" id="seat-1" r="7.1" cy="166" cx="21.8" />, | ||||
|     <circle key="seat-2" id="seat-2" r="7.1" cy="172" cx="51.5" />, | ||||
|     <circle key="seat-3" id="seat-3" r="7.1" cy="174.5" cx="82.7" />, | ||||
|     <circle key="seat-4" id="seat-4" r="7.1" cy="147.4" cx="21.5" />, | ||||
|     <circle key="seat-5" id="seat-5" r="7.1" cy="155.2" cx="51.8" />, | ||||
|     <circle key="seat-6" id="seat-6" r="7.1" cy="156.3" cx="83.4" />, | ||||
|     <circle key="seat-7" id="seat-7" r="7.1" cy="169.9" cx="120.9" />, | ||||
|     <circle key="seat-8" id="seat-8" r="7.1" cy="128.4" cx="22.8" />, | ||||
|     <circle key="seat-9" id="seat-9" r="7.1" cy="137.9" cx="53.2" />, | ||||
|     <circle key="seat-10" id="seat-10" r="7.1" cy="138.8" cx="85.5" />, | ||||
|     <circle key="seat-11" id="seat-11" r="7.1" cy="151.9" cx="120.9" />, | ||||
|     <circle key="seat-12" id="seat-12" r="7.1" cy="109" cx="25.6" />, | ||||
|     <circle key="seat-13" id="seat-13" r="7.1" cy="121.3" cx="57.2" />, | ||||
|     <circle key="seat-14" id="seat-14" r="7.1" cy="91.5" cx="34.2" />, | ||||
|     <circle key="seat-15" id="seat-15" r="7.1" cy="105.7" cx="64.8" />, | ||||
|     <circle key="seat-16" id="seat-16" r="7.1" cy="122.5" cx="92.9" />, | ||||
|     <circle key="seat-17" id="seat-17" r="7.1" cy="136.2" cx="128.2" />, | ||||
|     <circle key="seat-18" id="seat-18" r="7.1" cy="75.5" cx="45.3" />, | ||||
|     <circle key="seat-19" id="seat-19" r="7.1" cy="91.3" cx="75.7" />, | ||||
|     <circle key="seat-20" id="seat-20" r="7.1" cy="106.5" cx="106.3" />, | ||||
|     <circle key="seat-21" id="seat-21" r="7.1" cy="59.8" cx="57.9" />, | ||||
|     <circle key="seat-22" id="seat-22" r="7.1" cy="78.6" cx="89.5" />, | ||||
|     <circle key="seat-23" id="seat-23" r="7.1" cy="45.3" cx="73.2" />, | ||||
|     <circle key="seat-24" id="seat-24" r="7.1" cy="67.2" cx="104.6" />, | ||||
|     <circle key="seat-25" id="seat-25" r="7.1" cy="94.3" cx="121.6" />, | ||||
|     <circle key="seat-26" id="seat-26" r="7.1" cy="124.3" cx="141.1" />, | ||||
|     <circle key="seat-27" id="seat-27" r="7.1" cy="32.7" cx="90.8" />, | ||||
|     <circle key="seat-28" id="seat-28" r="7.1" cy="58.3" cx="120.9" />, | ||||
|     <circle key="seat-29" id="seat-29" r="7.1" cy="84.9" cx="139.1" />, | ||||
|     <circle key="seat-30" id="seat-30" r="7.1" cy="116.4" cx="157.2" />, | ||||
|     <circle key="seat-31" id="seat-31" r="7.1" cy="24.6" cx="109.5" />, | ||||
|     <circle key="seat-32" id="seat-32" r="7.1" cy="52.2" cx="138.6" />, | ||||
|     <circle key="seat-33" id="seat-33" r="7.1" cy="79.5" cx="157.8" />, | ||||
|     <circle key="seat-34" id="seat-34" r="7.1" cy="17.9" cx="128.8" />, | ||||
|     <circle key="seat-35" id="seat-35" r="7.1" cy="15.2" cx="147.7" />, | ||||
|     <circle key="seat-36" id="seat-36" r="7.1" cy="48.3" cx="156.9" />, | ||||
|     <circle key="seat-37" id="seat-37" r="7.1" cy="15.2" cx="192.5" />, | ||||
|     <circle key="seat-38" id="seat-38" r="7.1" cy="48.3" cx="183.3" />, | ||||
|     <circle key="seat-39" id="seat-39" r="7.1" cy="79.5" cx="182.4" />, | ||||
|     <circle key="seat-40" id="seat-40" r="7.1" cy="115.8" cx="182.2" />, | ||||
|     <circle key="seat-41" id="seat-41" r="7.1" cy="17.9" cx="211.4" />, | ||||
|     <circle key="seat-42" id="seat-42" r="7.1" cy="52.2" cx="201.6" />, | ||||
|     <circle key="seat-43" id="seat-43" r="7.1" cy="24.6" cx="230.7" />, | ||||
|     <circle key="seat-44" id="seat-44" r="7.1" cy="58.3" cx="219.3" />, | ||||
|     <circle key="seat-45" id="seat-45" r="7.1" cy="84.9" cx="201.1" />, | ||||
|     <circle key="seat-46" id="seat-46" r="7.1" cy="32.7" cx="249.4" />, | ||||
|     <circle key="seat-47" id="seat-47" r="7.1" cy="67.2" cx="235.6" />, | ||||
|     <circle key="seat-48" id="seat-48" r="7.1" cy="94.3" cx="218.6" />, | ||||
|     <circle key="seat-49" id="seat-49" r="7.1" cy="124.3" cx="199.1" />, | ||||
|     <circle key="seat-50" id="seat-50" r="7.1" cy="45.3" cx="267" />, | ||||
|     <circle key="seat-51" id="seat-51" r="7.1" cy="59.8" cx="282.3" />, | ||||
|     <circle key="seat-52" id="seat-52" r="7.1" cy="78.6" cx="250.7" />, | ||||
|     <circle key="seat-53" id="seat-53" r="7.1" cy="106.5" cx="234" />, | ||||
|     <circle key="seat-54" id="seat-54" r="7.1" cy="136.2" cx="212" />, | ||||
|     <circle key="seat-55" id="seat-55" r="7.1" cy="75.5" cx="294.9" />, | ||||
|     <circle key="seat-56" id="seat-56" r="7.1" cy="91.3" cx="264.5" />, | ||||
|     <circle key="seat-57" id="seat-57" r="7.1" cy="91.5" cx="306" />, | ||||
|     <circle key="seat-58" id="seat-58" r="7.1" cy="105.7" cx="275.4" />, | ||||
|     <circle key="seat-59" id="seat-59" r="7.1" cy="122.5" cx="247.3" />, | ||||
|     <circle key="seat-60" id="seat-60" r="7.1" cy="109" cx="313.5" />, | ||||
|     <circle key="seat-61" id="seat-61" r="7.1" cy="121.3" cx="283" />, | ||||
|     <circle key="seat-62" id="seat-62" r="7.1" cy="138.8" cx="254.7" />, | ||||
|     <circle key="seat-63" id="seat-63" r="7.1" cy="151.9" cx="219.3" />, | ||||
|     <circle key="seat-64" id="seat-64" r="7.1" cy="128.4" cx="317.4" />, | ||||
|     <circle key="seat-65" id="seat-65" r="7.1" cy="137.9" cx="287" />, | ||||
|     <circle key="seat-66" id="seat-66" r="7.1" cy="156.3" cx="256.8" />, | ||||
|     <circle key="seat-67" id="seat-67" r="7.1" cy="169.9" cx="219.3" />, | ||||
|     <circle key="seat-68" id="seat-68" r="7.1" cy="147.4" cx="318.7" />, | ||||
|     <circle key="seat-69" id="seat-69" r="7.1" cy="155.2" cx="288.4" />, | ||||
|     <circle key="seat-70" id="seat-70" r="7.1" cy="166" cx="318.4" />, | ||||
|     <circle key="seat-71" id="seat-71" r="7.1" cy="172" cx="288.7" />, | ||||
|     <circle key="seat-72" id="seat-72" r="7.1" cy="174.5" cx="257.5" />, | ||||
|   ]; | ||||
|  | ||||
|   let seatIndex = 1; // Empezamos desde 1 porque el 0 es para el presidente | ||||
|  | ||||
|   return ( | ||||
|     <svg viewBox="0 0 340.2 220.5" width={size} height={size * (220.5 / 340.2)} style={{ display: 'block', margin: 'auto' }}> | ||||
|       <g> | ||||
|         {/* Renderizamos primero el escaño del presidente por separado */} | ||||
|         {presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], { | ||||
|           fill: presidenteBancada.color || '#A9A9A9', | ||||
|           strokeWidth: 0.5, | ||||
|         })} | ||||
|  | ||||
|         {/* Mapeamos los partidos para crear los bloques */} | ||||
|         {partyData.map(partido => { | ||||
|           const partySeats = [ | ||||
|             ...Array(partido.bancasFijos).fill({ isNew: false }), | ||||
|             ...Array(partido.bancasGanadas).fill({ isNew: true }) | ||||
|           ]; | ||||
|            | ||||
|           return ( | ||||
|             <g | ||||
|               key={partido.id} | ||||
|               className="party-block" | ||||
|               data-tooltip-id="party-tooltip" | ||||
|               data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`} | ||||
|             > | ||||
|               {partySeats.map((seatInfo, i) => { | ||||
|                 if (seatIndex >= seatElements.length) return null; | ||||
|  | ||||
|                 const template = seatElements[seatIndex]; | ||||
|                 seatIndex++; | ||||
|  | ||||
|                 return React.cloneElement(template, { | ||||
|                   key: `${partido.id}-${i}`, | ||||
|                   className: 'seat-circle', | ||||
|                   fill: partido.color || '#808080', | ||||
|                   fillOpacity: seatInfo.isNew ? 1 : 0.3, | ||||
|                   stroke: partido.color || '#808080', | ||||
|                   strokeWidth: 0.5, | ||||
|                 }); | ||||
|               })} | ||||
|             </g> | ||||
|           ); | ||||
|         })} | ||||
|         {/* Renderizamos escaños vacíos si sobran */} | ||||
|         {seatIndex < seatElements.length && | ||||
|           seatElements.slice(seatIndex).map((template, i) =>  | ||||
|             React.cloneElement(template, { | ||||
|               key: `empty-${i}`, | ||||
|               fill: '#E0E0E0', | ||||
|               stroke: '#ffffff', | ||||
|               strokeWidth: 0.5 | ||||
|             }) | ||||
|           ) | ||||
|         } | ||||
|       </g> | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,6 +1,7 @@ | ||||
| // src/components/SenateLayout.tsx
 | ||||
| // src/components/common/SenateLayout.tsx
 | ||||
| import React, { useLayoutEffect } from 'react'; | ||||
| import { handleImageFallback } from './imageFallback'; | ||||
| import { assetBaseUrl } from '../../apiService'; | ||||
| 
 | ||||
| // Interfaces
 | ||||
| interface SeatFillData { | ||||
| @@ -28,7 +29,7 @@ export const SenateLayout: React.FC<SenateLayoutProps> = ({ | ||||
|   // HOOK DE IMAGENES POR DEFECTO
 | ||||
|   useLayoutEffect(() => { | ||||
|     // Se ejecuta después de que el componente y el tooltip se hayan renderizado
 | ||||
|     handleImageFallback('.seat-tooltip img', '/default-avatar.png'); | ||||
|     handleImageFallback('.seat-tooltip img', `${assetBaseUrl}/default-avatar.png`); | ||||
|   }, [seatData, presidenteBancada]); // Dependencias: se vuelve a ejecutar si estos datos cambian
 | ||||
| 
 | ||||
|   const uniqueColors = [...new Set(seatData.map(d => d.color).filter(Boolean))]; | ||||
| @@ -120,7 +121,7 @@ export const SenateLayout: React.FC<SenateLayoutProps> = ({ | ||||
|       strokeWidth: 1.5, | ||||
|       'data-tooltip-id': seat.ocupante ? 'seat-tooltip' : undefined, | ||||
|       'data-tooltip-html': seat.ocupante | ||||
|         ? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || '/default-avatar.png'}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>` | ||||
|         ? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || `${assetBaseUrl}/default-avatar.png`}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>` | ||||
|         : undefined, | ||||
|     }); | ||||
|   }); | ||||
| @@ -1,4 +1,4 @@ | ||||
| // src/components/imageFallback.ts
 | ||||
| // src/components/common/imageFallback.ts
 | ||||
| 
 | ||||
| export function handleImageFallback(selector: string, fallbackImageUrl: string) { | ||||
|   // Le decimos a TypeScript que el resultado será una lista de elementos de imagen HTML
 | ||||
| @@ -0,0 +1,215 @@ | ||||
| // src/features/legislativas/nacionales/DevAppLegislativas.tsx | ||||
| import { useState } from 'react'; // <-- Importar useState | ||||
| import { ResultadosNacionalesCardsWidget } from './nacionales/ResultadosNacionalesCardsWidget'; | ||||
| import { CongresoNacionalWidget } from './nacionales/CongresoNacionalWidget'; | ||||
| import { PanelNacionalWidget } from './nacionales/PanelNacionalWidget'; | ||||
| import { HomeCarouselWidget } from './nacionales/HomeCarouselWidget'; | ||||
| import './DevAppStyle.css' | ||||
| import { HomeCarouselNacionalWidget } from './nacionales/HomeCarouselNacionalWidget'; | ||||
| import { TablaConurbanoWidget } from './nacionales/TablaConurbanoWidget'; | ||||
| import { TablaSeccionesWidget } from './nacionales/TablaSeccionesWidget'; | ||||
| import { ResumenNacionalWidget } from './nacionales/ResumenNacionalWidget'; | ||||
| import { HomeCarouselProvincialWidget } from './nacionales/HomeCarouselProvincialWidget'; | ||||
|  | ||||
| // --- NUEVO COMPONENTE REUTILIZABLE PARA CONTENIDO COLAPSABLE --- | ||||
| const CollapsibleWidgetWrapper = ({ children }: { children: React.ReactNode }) => { | ||||
|     const [isExpanded, setIsExpanded] = useState(false); | ||||
|  | ||||
|     return ( | ||||
|         <div className="collapsible-container"> | ||||
|             <div className={`collapsible-content ${isExpanded ? 'expanded' : ''}`}> | ||||
|                 {children} | ||||
|             </div> | ||||
|             <button className="toggle-button" onClick={() => setIsExpanded(!isExpanded)}> | ||||
|                 {isExpanded ? 'Mostrar Menos' : 'Mostrar Más'} | ||||
|             </button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const DevAppLegislativas = () => { | ||||
|     // Estilos para los separadores y descripciones para mejorar la legibilidad | ||||
|     const sectionStyle = { | ||||
|         border: '2px solid #007bff', | ||||
|         borderRadius: '8px', | ||||
|         padding: '2px', | ||||
|         marginTop: '3rem', | ||||
|         marginBottom: '3rem', | ||||
|         backgroundColor: '#f8f9fa' | ||||
|     }; | ||||
|     const descriptionStyle = { | ||||
|         fontFamily: 'sans-serif', | ||||
|         color: '#333', | ||||
|         lineHeight: 1.6 | ||||
|     }; | ||||
|     const codeStyle = { | ||||
|         backgroundColor: '#e9ecef', | ||||
|         padding: '2px 6px', | ||||
|         borderRadius: '4px', | ||||
|         fontFamily: 'Roboto' | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="container-legislativas2025"> | ||||
|             <h1>Visor de Widgets</h1> | ||||
|  | ||||
|             <CongresoNacionalWidget eleccionId={2} /> | ||||
|             <PanelNacionalWidget eleccionId={2} /> | ||||
|  | ||||
|             <div style={sectionStyle}> | ||||
|                 <h2>Widget: Carrusel de Resultados Provincias (Home)</h2> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Uso: <code style={codeStyle}><HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={3} titulo="Diputados - Provincia de Buenos Aires" mapLinkUrl={''} /></code> | ||||
|                 </p> | ||||
|                 <HomeCarouselWidget | ||||
|                     eleccionId={2} // Nacional | ||||
|                     distritoId="02" // Buenos Aires | ||||
|                     categoriaId={3} // Diputados Nacionales | ||||
|                     titulo="Diputados - Provincia de Buenos Aires" | ||||
|                     mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios" | ||||
|                 /> | ||||
|             </div> | ||||
|  | ||||
|             <div style={sectionStyle}> | ||||
|                 <h2>Widget: Carrusel de Resultados Nación (Home)</h2> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Uso: <code style={codeStyle}><HomeCarouselNacionalWidget eleccionId={2} categoriaId={3} titulo="Diputados - Total País" mapLinkUrl={''} /></code> | ||||
|                 </p> | ||||
|                 <HomeCarouselNacionalWidget | ||||
|                     eleccionId={2} | ||||
|                     categoriaId={3} // 3 para Diputados, 2 para Senadores | ||||
|                     titulo="Diputados - Total País" | ||||
|                     mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios" | ||||
|                 /> | ||||
|             </div> | ||||
|  | ||||
|             <div style={sectionStyle}> | ||||
|                 <h2>Widget: Carrusel de Resultados Nación (Home)</h2> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Uso: <code style={codeStyle}><HomeCarouselNacionalWidget eleccionId={2} categoriaId={2} titulo="Senadores - Total País" /></code> | ||||
|                 </p> | ||||
|                 <HomeCarouselNacionalWidget | ||||
|                     eleccionId={2} | ||||
|                     categoriaId={2} // 3 para Diputados, 2 para Senadores | ||||
|                     titulo="Senadores - Total País" mapLinkUrl={''} /> | ||||
|             </div> | ||||
|  | ||||
|             <div style={sectionStyle}> | ||||
|                 <h2>Widget: Carrusel Provincial con Selector (Home)</h2> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Categoría Diputados | ||||
|                 </p> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={3} titulo="Diputados" /></code> | ||||
|                 </p> | ||||
|                 <HomeCarouselProvincialWidget | ||||
|                     eleccionId={2} | ||||
|                     categoriaId={3} // 3 para Diputados, 2 para Senadores | ||||
|                     titulo="Diputados" | ||||
|                 /> | ||||
|  | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Categoría Senadores | ||||
|                 </p> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={2} titulo="Senadores" /></code> | ||||
|                 </p> | ||||
|                 <HomeCarouselProvincialWidget | ||||
|                     eleccionId={2} | ||||
|                     categoriaId={2} // 3 para Diputados, 2 para Senadores | ||||
|                     titulo="Senadores" | ||||
|                 /> | ||||
|             </div> | ||||
|  | ||||
|             {/* --- SECCIÓN PARA EL WIDGET DE TARJETAS CON EJEMPLOS --- */} | ||||
|             <div style={sectionStyle}> | ||||
|                 <h2>Widget: Resultados por Provincia (Tarjetas)</h2> | ||||
|  | ||||
|                 <hr /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>1. Vista por Defecto</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Sin parámetros adicionales. Muestra todas las provincias, con sus categorías correspondientes (Diputados para las 24, Senadores para las 8 que renuevan). Muestra los 2 principales partidos por defecto. | ||||
|                     <br /> | ||||
|                     Uso: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} /></code> | ||||
|                 </p> | ||||
|                 <CollapsibleWidgetWrapper> | ||||
|                     <ResultadosNacionalesCardsWidget eleccionId={2} /> | ||||
|                 </CollapsibleWidgetWrapper> | ||||
|  | ||||
|                 <hr style={{ marginTop: '2rem' }} /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>2. Filtrado por Provincia (focoDistritoId)</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Muestra únicamente la tarjeta de una provincia específica. Ideal para páginas de noticias locales. El ID de distrito ("02" para Bs. As., "06" para Chaco) se pasa como prop. | ||||
|                     <br /> | ||||
|                     Ejemplo Buenos Aires: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" /></code> | ||||
|                 </p> | ||||
|                 <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" /> | ||||
|  | ||||
|                 <p style={{ ...descriptionStyle, marginTop: '2rem' }}> | ||||
|                     Ejemplo Chaco (que también renueva Senadores): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" /></code> | ||||
|                 </p> | ||||
|                 <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" /> | ||||
|  | ||||
|                 <hr style={{ marginTop: '2rem' }} /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>3. Filtrado por Categoría (focoCategoriaId)</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Muestra todas las provincias que votan para una categoría específica. | ||||
|                     <br /> | ||||
|                     Ejemplo Senadores (ID 2): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} /></code> | ||||
|                 </p> | ||||
|                 <ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} /> | ||||
|  | ||||
|                 <hr style={{ marginTop: '2rem' }} /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>4. Indicando Cantidad de Resultados (cantidadResultados)</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Controla cuántos partidos se muestran en cada categoría. Por defecto son 2. | ||||
|                     <br /> | ||||
|                     Ejemplo mostrando el TOP 3 de cada categoría: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} /></code> | ||||
|                 </p> | ||||
|                 <CollapsibleWidgetWrapper> | ||||
|                     <ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} /> | ||||
|                 </CollapsibleWidgetWrapper> | ||||
|  | ||||
|                 <hr style={{ marginTop: '2rem' }} /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>5. Mostrando las Bancas (mostrarBancas)</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Útil para contextos donde importan las bancas. La prop <code style={codeStyle}>mostrarBancas</code> se establece en <code style={codeStyle}>true</code>. | ||||
|                     <br /> | ||||
|                     Ejemplo en Tierra del Fuego: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} /></code> | ||||
|                 </p> | ||||
|                 <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} /> | ||||
|  | ||||
|                 <hr style={{ marginTop: '2rem' }} /> | ||||
|  | ||||
|                 <h3 style={{ marginTop: '2rem' }}>6. Combinación de Parámetros</h3> | ||||
|                 <p style={descriptionStyle}> | ||||
|                     Se pueden combinar todos los parámetros para vistas muy específicas. | ||||
|                     <br /> | ||||
|                     Ejemplo: Mostrar el TOP 1 (el ganador) para la categoría de SENADORES en la provincia de RÍO NEGRO (Distrito ID "16"). | ||||
|                     <br /> | ||||
|                     Uso: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} /></code> | ||||
|                 </p> | ||||
|                 <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} /> | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2>Widget: Tabla de Resultados del Conurbano</h2> | ||||
|                     <TablaConurbanoWidget /> | ||||
|                 </div> | ||||
|  | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2>Widget: Tabla de Resultados por Sección Electoral</h2> | ||||
|                     <TablaSeccionesWidget /> | ||||
|                 </div> | ||||
|  | ||||
|                 <div style={sectionStyle}> | ||||
|                     <h2>Resumen Nacional de Resultados por Provincia</h2> | ||||
|                     <ResumenNacionalWidget /> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,50 @@ | ||||
| .container-legislativas2025{ | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS PARA EL CONTENEDOR COLAPSABLE --- */ | ||||
|  | ||||
| .collapsible-container { | ||||
|     position: relative; | ||||
|     padding-bottom: 50px; /* Espacio para el botón de expandir */ | ||||
| } | ||||
|  | ||||
| .collapsible-content { | ||||
|     max-height: 950px; /* Altura suficiente para 2 filas de tarjetas (aprox) */ | ||||
|     overflow: hidden; | ||||
|     transition: max-height 0.7s ease-in-out; | ||||
|     position: relative; | ||||
| } | ||||
|  | ||||
| .collapsible-content.expanded { | ||||
|     max-height: 100%; /* Un valor grande para asegurar que todo el contenido sea visible */ | ||||
| } | ||||
|  | ||||
| /* Pseudo-elemento para crear un degradado y sugerir que hay más contenido */ | ||||
| .collapsible-content:not(.expanded)::after { | ||||
|     content: ''; | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     height: 150px; | ||||
|     background: linear-gradient(to top, rgba(248, 249, 250, 1) 20%, rgba(248, 249, 250, 0)); | ||||
|     pointer-events: none; /* Permite hacer clic a través del degradado */ | ||||
| } | ||||
|  | ||||
| .toggle-button { | ||||
|     position: absolute; | ||||
|     bottom: 10px; | ||||
|     left: 50%; | ||||
|     transform: translateX(-50%); | ||||
|     padding: 10px 20px; | ||||
|     font-size: 1rem; | ||||
|     font-weight: bold; | ||||
|     color: #fff; | ||||
|     background-color: #007bff; | ||||
|     border: none; | ||||
|     border-radius: 20px; | ||||
|     cursor: pointer; | ||||
|     box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); | ||||
|     z-index: 2; | ||||
| } | ||||
| @@ -0,0 +1,198 @@ | ||||
| /* src/features/legislativas/nacionales/CongresoNacionalWidget.module.css */ | ||||
|  | ||||
| /* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */ | ||||
| .congresoContainer, | ||||
| .congresoContainer * { | ||||
|   font-family: "Roboto", system-ui, sans-serif !important; | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| /* --- Reseteos Generales --- */ | ||||
| .congresoContainer h1, .congresoContainer h2, .congresoContainer h3, .congresoContainer h4, .congresoContainer h5, .congresoContainer h6, .congresoContainer div, .congresoContainer p, .congresoContainer strong, .congresoContainer em, .congresoContainer b, .congresoContainer i { | ||||
|     line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline; border: 0; | ||||
| } | ||||
|  | ||||
| .congresoContainer span{ | ||||
|   line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline; | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS BASE (VISTA ANCHA/ESCRITORIO) --- */ | ||||
| .congresoContainer { | ||||
|   display: flex; flex-direction: row; align-items: stretch; gap: 1.5rem; | ||||
|   background-color: #ffffff; border: 1px solid #e0e0e0; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|   padding: 1rem; border-radius: 8px; max-width: 900px; margin: 20px auto; | ||||
|   color: #333333; --primary-accent-color: #007bff; height: 500px; | ||||
|   container-type: inline-size; container-name: congreso-widget; | ||||
| } | ||||
|  | ||||
| .congresoGrafico { flex: 2; min-width: 300px; display: flex; flex-direction: column; } | ||||
| .congresoHemicicloWrapper { flex-grow: 1; display: flex; align-items: center; justify-content: center; width: 100%; } | ||||
| .congresoHemicicloWrapper.isHovering :global(.party-block:not(:hover)) { opacity: 0.3; } | ||||
| .congresoGrafico svg { width: 100%; height: auto; } | ||||
| .congresoFooter { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0.5rem 0 0.5rem; margin-top: auto; font-size: 0.8em; color: #666; border-top: 1px solid #eee; } | ||||
| .footerLegend { display: flex; gap: 1.25rem; align-items: center; } | ||||
| .footerLegendItem { display: flex; align-items: center; gap: 0.6rem; font-size: 1.1em; } | ||||
| .legendIcon { display: inline-block; width: 14px; height: 14px; border-radius: 50%; } | ||||
| .legendIconSolid { background-color: #888; } | ||||
| .legendIconRing {  | ||||
|   background-color: rgba(136, 136, 136, 0.3); | ||||
|   border: 1px solid #3a3a3a; | ||||
| } | ||||
| .footerTimestamp { font-weight: 500; font-size: 0.75em; text-align: right; } | ||||
| .congresoSummary { flex: 1; border-left: 1px solid #e0e0e0; padding-left: 1.25rem; display: flex; flex-direction: column; justify-content: flex-start; } | ||||
|  | ||||
| .summaryHeader { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center;  /* Centra el título y la barra de pestañas */ | ||||
|   gap: 0.75rem;         /* Espacio vertical entre el título y las pestañas */ | ||||
|   margin-bottom: 1rem;  /* Espacio entre la cabecera y el resto del contenido */ | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .congresoSummary h3 { | ||||
|   text-align: center; | ||||
|   margin: 0; /* Quitamos el margen para que el 'gap' del header lo controle */ | ||||
|   font-size: 1.4em; | ||||
|   font-weight: 700; | ||||
|   color: #212529; | ||||
| } | ||||
|  | ||||
| .chamberTabs { | ||||
|   display: flex; | ||||
|   margin-bottom: 0; /* Quitamos el margen para que el 'gap' del header lo controle */ | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 6px; | ||||
|   overflow: hidden; | ||||
|   width: 100%; /* Hacemos que la barra de pestañas ocupe todo el ancho del header */ | ||||
| } | ||||
|  | ||||
| .chamberTabs button { flex: 1; padding: 0.5rem; border: none; background-color: #f8f9fa; color: #6c757d; font-family: inherit; font-size: 1em; font-weight: 500; cursor: pointer; transition: all 0.2s ease-in-out; text-align: center; } | ||||
| .chamberTabs button:first-child { border-right: 1px solid #dee2e6; } | ||||
| .chamberTabs button:hover { background-color: #e9ecef; } | ||||
| .chamberTabs button.active { background-color: var(--primary-accent-color); color: #ffffff; } | ||||
| .summaryMetric { display: flex; justify-content: space-between; align-items: baseline; margin-top: 0.25rem; margin-bottom: 0.25rem; font-size: 1.1em; } | ||||
| .summaryMetric strong { font-size: 1.25em; font-weight: 700; color: var(--primary-accent-color); } | ||||
| .congresoSummary hr { border: none; border-top: 1px solid #e0e0e0; margin: 1rem 0; } | ||||
| .partidoListaContainer { flex-grow: 1; overflow-y: auto; min-height: 0; } | ||||
| .partidoLista { list-style: none; padding: 0; margin: 0; padding-right: 8px; } | ||||
| .partidoLista li { display: flex; align-items: center; margin-bottom: 0.85rem; } | ||||
| .partidoColorBox { width: 16px; height: 16px; border-radius: 4px; margin-right: 12px; flex-shrink: 0; } | ||||
| .partidoNombre { flex-grow: 1; font-size: 1em; } | ||||
| .partidoBancas { font-weight: 700; font-size: 1.1em; } | ||||
|  | ||||
|  | ||||
| /* --- REGLA #1: RESPONSIVIDAD EXTERNA Y LAYOUT PRINCIPAL (MÓVIL) --- */ | ||||
| @media (max-width: 768px) { | ||||
|   .congresoContainer { | ||||
|     /* Forzar el comportamiento externo */ | ||||
|     width: 100% !important; | ||||
|     flex-basis: 100% !important; | ||||
|     grid-column: 1 / -1 !important; | ||||
|     max-width: none !important; | ||||
|     margin-left: auto !important; | ||||
|     margin-right: auto !important; | ||||
|     flex-direction: column !important; | ||||
|     height: auto !important; | ||||
|     max-height: none !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* --- REGLA #2: AJUSTES FINOS INTERNOS CUANDO EL WIDGET ES ESTRECHO --- */ | ||||
| @container congreso-widget (max-width: 700px) { | ||||
|   /* La dirección del flex ya fue establecida por la @media query. */ | ||||
|   /* Aquí solo hacemos los ajustes de contenido. */ | ||||
|    | ||||
|   .congresoGrafico { min-width: 0; } | ||||
|   .congresoSummary { border-left: none; padding-left: 0; border-top: 1px solid #e0e0e0; padding-top: 1rem; margin-top: 1rem; } | ||||
|   .congresoSummary h3 { font-size: 1.25em; } | ||||
|   .summaryMetric { font-size: 1em; } | ||||
|   .summaryMetric strong { font-size: 1.3em; } | ||||
|   .partidoNombre { font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | ||||
|   .partidoBancas { font-size: 1em; } | ||||
|   .partidoListaContainer { overflow-y: visible; max-height: none; } | ||||
|   .footerLegend { gap: 1rem; } | ||||
|   .footerLegendItem{ font-size: 0.9em; } | ||||
|   .congresoFooter { flex-direction: column; align-items: center; gap: 0.75rem; padding: 0.75rem 0rem; } | ||||
| } | ||||
|  | ||||
| .partidoListaContainer { | ||||
|   scrollbar-width: thin; /* Hace el scrollbar más delgado */ | ||||
|   scrollbar-color: #c1c1c1 #f1f1f1; /* Color del thumb y del track */ | ||||
| } | ||||
| .partidoListaContainer::-webkit-scrollbar { | ||||
|   width: 8px; | ||||
| } | ||||
|  | ||||
| /* Estilo del "track" o canal por donde se mueve el scrollbar */ | ||||
| .partidoListaContainer::-webkit-scrollbar-track { | ||||
|   background: #f1f1f1; | ||||
|   border-radius: 10px; | ||||
| } | ||||
|  | ||||
| /* Estilo del "thumb" o la barra que se arrastra */ | ||||
| .partidoListaContainer::-webkit-scrollbar-thumb { | ||||
|   background: #c1c1c1; | ||||
|   border-radius: 10px; | ||||
|   border: 2px solid #f1f1f1; | ||||
| } | ||||
|  | ||||
| /* Estilo del "thumb" al pasar el mouse por encima */ | ||||
| .partidoListaContainer::-webkit-scrollbar-thumb:hover { | ||||
|   background: #a8a8a8; | ||||
| } | ||||
|  | ||||
| /* --- A. Tooltip de FOTO DE LEGISLADOR (seat-tooltip) --- */ | ||||
|  | ||||
| :global(#seat-tooltip.react-tooltip) { | ||||
|     opacity: 1 !important; | ||||
|     background-color: #ffffff !important; | ||||
|     border-radius: 6px !important; | ||||
|     box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important; | ||||
|     padding: 0 !important; | ||||
|     z-index: 9999 !important; | ||||
| } | ||||
|  | ||||
| :global(.seat-tooltip) { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   gap: 5px; | ||||
|   padding: 8px; | ||||
| } | ||||
|  | ||||
| :global(.seat-tooltip img) { | ||||
|     width: 60px; | ||||
|     height: 60px; | ||||
|     border-radius: 50%; | ||||
|     object-fit: cover; | ||||
|     border: 2px solid #ccc; | ||||
| } | ||||
|  | ||||
| :global(.seat-tooltip p) { | ||||
|     margin: 0; | ||||
|     font-size: 12px; | ||||
|     font-weight: bold; | ||||
|     font-family: "Roboto", system-ui, sans-serif !important; | ||||
|     color: #333333 !important; | ||||
|     text-align: center !important; | ||||
| } | ||||
|  | ||||
| /* --- B. ¡NUEVO! Tooltip de BLOQUE DE PARTIDO (party-tooltip) --- */ | ||||
|  | ||||
| :global(#party-tooltip.react-tooltip) { | ||||
|     opacity: 1 !important; | ||||
|     background-color: #333333 !important; /* Fondo oscuro, como el nativo */ | ||||
|     border-radius: 4px !important; | ||||
|     padding: 4px 8px !important; /* Padding interno */ | ||||
|     z-index: 9998 !important; /* Ligeramente por debajo del otro por si acaso */ | ||||
|     pointer-events: none; /* Evita que el tooltip interfiera con el mouse */ | ||||
| } | ||||
|  | ||||
| /* Usamos la clase que añadimos en el TSX para estilizar el contenido */ | ||||
| .partyTooltipContainer { | ||||
|     font-size: 13px !important; | ||||
|     font-family: "Roboto", system-ui, sans-serif !important; | ||||
|     color: #ffffff !important; /* Letras blancas para contrastar con el fondo oscuro */ | ||||
|     font-weight: 500 !important; | ||||
| } | ||||