Compare commits
	
		
			71 Commits
		
	
	
		
			11d9417ef5
			...
			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 | |||
| ed5b78e6c8 | |||
| a985cbfd7c | |||
| 3b0eee25e6 | |||
| 67634ae947 | |||
| 5a8bee52d5 | |||
| 3750d1a56d | |||
| 7d2922aaeb | |||
| 3a8f64bf85 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -28,6 +28,7 @@ build/ | |||||||
| *.userprefs | *.userprefs | ||||||
| /bin/ | /bin/ | ||||||
| /obj/ | /obj/ | ||||||
|  | /debug/ | ||||||
| project.lock.json | project.lock.json | ||||||
| project.assets.json | project.assets.json | ||||||
| /packages/ | /packages/ | ||||||
| @@ -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; |     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 { | .sortable-list-horizontal { | ||||||
|   list-style: none; |   list-style: none; | ||||||
|   padding: 8px; |   padding: 8px; | ||||||
|   | |||||||
| @@ -1,174 +1,154 @@ | |||||||
| // src/components/AgrupacionesManager.tsx | // EN: src/components/AgrupacionesManager.tsx | ||||||
| import { useState, useEffect } from 'react'; | import { useState, useEffect } from 'react'; | ||||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||||
|  | import Select from 'react-select'; | ||||||
| import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService'; | 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'; | import './AgrupacionesManager.css'; | ||||||
|  |  | ||||||
| const SENADORES_ID = 5; | const GLOBAL_ELECTION_ID = 0; | ||||||
| const DIPUTADOS_ID = 6; |  | ||||||
| const CONCEJALES_ID = 7; | 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)' } | ||||||
|  | ]; | ||||||
|  |  | ||||||
| // Esta función limpia cualquier carácter no válido de un string de color. |  | ||||||
| const sanitizeColor = (color: string | null | undefined): string => { | const sanitizeColor = (color: string | null | undefined): string => { | ||||||
|     if (!color) return '#000000'; // Devuelve un color válido por defecto si es nulo |     if (!color) return '#000000'; | ||||||
|     // Usa una expresión regular para eliminar todo lo que no sea un '#' o un carácter hexadecimal |     return color.startsWith('#') ? color : `#${color}`; | ||||||
|     const sanitized = color.replace(/[^#0-9a-fA-F]/g, ''); |  | ||||||
|     return sanitized.startsWith('#') ? sanitized : `#${sanitized}`; |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const AgrupacionesManager = () => { | export const AgrupacionesManager = () => { | ||||||
|     const queryClient = useQueryClient(); |     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[]>({ |     const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||||
|         queryKey: ['agrupaciones'], |         queryKey: ['agrupaciones'], queryFn: getAgrupaciones, | ||||||
|         queryFn: getAgrupaciones, |  | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     // Query 2: Obtener logos |  | ||||||
|     const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({ |     const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({ | ||||||
|         queryKey: ['logos'], |         queryKey: ['allLogos'], | ||||||
|         queryFn: getLogos, |         queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()), | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     useEffect(() => { |     const handleCreationSuccess = () => { | ||||||
|         // Solo procedemos si los datos de agrupaciones están disponibles |         // Invalida la query de agrupaciones para forzar una actualización | ||||||
|         if (agrupaciones && agrupaciones.length > 0) { |         queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); | ||||||
|             // Inicializamos el estado de 'editedAgrupaciones' una sola vez. |  | ||||||
|             // Usamos una función en setState para asegurarnos de que solo se ejecute |  | ||||||
|             // si el estado está vacío, evitando sobreescribir ediciones del usuario. |  | ||||||
|             setEditedAgrupaciones(prev => { |  | ||||||
|                 if (Object.keys(prev).length === 0) { |  | ||||||
|                     return Object.fromEntries(agrupaciones.map(a => [a.id, {}])); |  | ||||||
|                 } |  | ||||||
|                 return prev; |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         // Hacemos lo mismo para los logos |  | ||||||
|         if (logos && logos.length > 0) { |  | ||||||
|             setEditedLogos(prev => { |  | ||||||
|                 if (prev.length === 0) { |  | ||||||
|                     // Creamos una copia profunda para evitar mutaciones accidentales |  | ||||||
|                     return JSON.parse(JSON.stringify(logos)); |  | ||||||
|                 } |  | ||||||
|                 return prev; |  | ||||||
|             }); |  | ||||||
|         } |  | ||||||
|         // La dependencia ahora es el estado de carga. El hook se ejecutará cuando |  | ||||||
|         // isLoadingAgrupaciones o isLoadingLogos cambien de true a false. |  | ||||||
|     }, [agrupaciones, logos]); |  | ||||||
|  |  | ||||||
|     const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => { |  | ||||||
|         setEditedAgrupaciones(prev => ({ |  | ||||||
|             ...prev, |  | ||||||
|             [id]: { ...prev[id], [field]: value } |  | ||||||
|         })); |  | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const handleLogoChange = (agrupacionId: string, categoriaId: number, value: string) => { |     useEffect(() => { | ||||||
|         setEditedLogos(prev => { |         if (agrupaciones.length > 0) { | ||||||
|             const newLogos = [...prev]; |             const initialEdits = Object.fromEntries( | ||||||
|             const existing = newLogos.find(l => |                 agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }]) | ||||||
|                 l.agrupacionPoliticaId === agrupacionId && |  | ||||||
|                 l.categoriaId === categoriaId && |  | ||||||
|                 l.ambitoGeograficoId == null |  | ||||||
|             ); |             ); | ||||||
|  |             setEditedAgrupaciones(initialEdits); | ||||||
|  |         } | ||||||
|  |     }, [agrupaciones]); | ||||||
|  |  | ||||||
|             if (existing) { |     useEffect(() => { | ||||||
|                 existing.logoUrl = value; |         if (logos) { | ||||||
|             } else { |             const logoMap = Object.fromEntries( | ||||||
|                 newLogos.push({ |                 logos | ||||||
|                     id: 0, |                     // --- CORRECCIÓN CLAVE 1: Comprobar contra `null` en lugar de `0` --- | ||||||
|                     agrupacionPoliticaId: agrupacionId, |                     .filter(l => l.categoriaId === 0 && l.ambitoGeograficoId === null) | ||||||
|                     categoriaId, |                     .map(l => [`${l.agrupacionPoliticaId}-${l.eleccionId}`, l.logoUrl]) | ||||||
|                     logoUrl: value, |             ); | ||||||
|                     ambitoGeograficoId: null |             setEditedLogos(logoMap); | ||||||
|                 }); |         } | ||||||
|             } |     }, [logos]); | ||||||
|             return newLogos; |  | ||||||
|         }); |     const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => { | ||||||
|  |         setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } })); | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const handleLogoInputChange = (agrupacionId: string, value: string | null) => { | ||||||
|  |         const key = `${agrupacionId}-${selectedEleccion.value}`; | ||||||
|  |         setEditedLogos(prev => ({ ...prev, [key]: value })); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const handleSaveAll = async () => { |     const handleSaveAll = async () => { | ||||||
|         try { |         try { | ||||||
|             const agrupacionPromises = Object.entries(editedAgrupaciones).map(([id, changes]) => { |             const agrupacionPromises = agrupaciones.map(agrupacion => { | ||||||
|                 if (Object.keys(changes).length > 0) { |                 const changes = editedAgrupaciones[agrupacion.id] || {}; | ||||||
|                     const original = agrupaciones.find(a => a.id === id); |                 const payload: UpdateAgrupacionData = { | ||||||
|                     if (original) { // Chequeo de seguridad |                     nombreCorto: changes.nombreCorto ?? agrupacion.nombreCorto, | ||||||
|                         return updateAgrupacion(id, { ...original, ...changes }); |                     color: changes.color ?? agrupacion.color, | ||||||
|                     } |                 }; | ||||||
|                 } |                 return updateAgrupacion(agrupacion.id, payload); | ||||||
|                 return Promise.resolve(); |  | ||||||
|             }); |             }); | ||||||
|  |  | ||||||
|             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]); |             await Promise.all([...agrupacionPromises, logoPromise]); | ||||||
|  |  | ||||||
|             queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); |             await queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); | ||||||
|             queryClient.invalidateQueries({ queryKey: ['logos'] }); |             await queryClient.invalidateQueries({ queryKey: ['allLogos'] }); | ||||||
|  |  | ||||||
|             alert('¡Todos los cambios han sido guardados!'); |             alert('¡Todos los cambios han sido guardados!'); | ||||||
|         } catch (err) { |         } catch (err) { console.error("Error al guardar todo:", err); alert("Ocurrió un error."); } | ||||||
|             console.error("Error al guardar todo:", err); |     }; | ||||||
|             alert("Ocurrió un error al guardar los cambios."); |  | ||||||
|         } |     const getLogoValue = (agrupacionId: string): string => { | ||||||
|  |         const key = `${agrupacionId}-${selectedEleccion.value}`; | ||||||
|  |         return editedLogos[key] ?? ''; | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const isLoading = isLoadingAgrupaciones || isLoadingLogos; |     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 ( |     return ( | ||||||
|         <div className="admin-module"> |         <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> : ( |             {isLoading ? <p>Cargando...</p> : ( | ||||||
|                 <> |                 <> | ||||||
|                     <table> |                     <div className="table-container"> | ||||||
|                         <thead> |                         <table> | ||||||
|                             <tr> |                             <thead> | ||||||
|                                 <th>Nombre</th> |                                 <tr> | ||||||
|                                 <th>Nombre Corto</th> |                                     <th>Nombre</th> | ||||||
|                                 <th>Color</th> |                                     <th>Nombre Corto</th> | ||||||
|                                 <th>Logo Senadores</th> |                                     <th>Color</th> | ||||||
|                                 <th>Logo Diputados</th> |                                     <th>Logo</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"  |  | ||||||
|                                             // Usamos la función sanitizeColor para asegurarnos de que el valor sea siempre válido |  | ||||||
|                                             value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color)}  |  | ||||||
|                                             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> |  | ||||||
|                                 </tr> |                                 </tr> | ||||||
|                             ))} |                             </thead> | ||||||
|                         </tbody> |                             <tbody> | ||||||
|                     </table> |                                 {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' }}> |                     <button onClick={handleSaveAll} style={{ marginTop: '1rem' }}> | ||||||
|                         Guardar Todos los Cambios |                         Guardar Todos los Cambios | ||||||
|                     </button> |                     </button> | ||||||
|  |                     <AddAgrupacionForm onSuccess={handleCreationSuccess} /> | ||||||
|                 </> |                 </> | ||||||
|             )} |             )} | ||||||
|         </div> |         </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 { useState } from 'react'; | ||||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||||
| import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService'; | import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService'; | ||||||
| @@ -6,9 +6,10 @@ import type { Bancada, AgrupacionPolitica } from '../types'; | |||||||
| import { OcupantesModal } from './OcupantesModal'; | import { OcupantesModal } from './OcupantesModal'; | ||||||
| import './AgrupacionesManager.css'; | import './AgrupacionesManager.css'; | ||||||
| 
 | 
 | ||||||
|  | const ELECCION_ID_PROVINCIAL = 1; | ||||||
| const camaras = ['diputados', 'senadores'] as const; | const camaras = ['diputados', 'senadores'] as const; | ||||||
| 
 | 
 | ||||||
| export const BancasManager = () => { | export const BancasProvincialesManager = () => { | ||||||
|   const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados'); |   const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados'); | ||||||
|   const [modalVisible, setModalVisible] = useState(false); |   const [modalVisible, setModalVisible] = useState(false); | ||||||
|   const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null); |   const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null); | ||||||
| @@ -19,16 +20,18 @@ export const BancasManager = () => { | |||||||
|     queryFn: getAgrupaciones |     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[]>({ |   const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({ | ||||||
|     queryKey: ['bancadas', activeTab], |     queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL], | ||||||
|     queryFn: () => getBancadas(activeTab), |     queryFn: () => getBancadas(activeTab, ELECCION_ID_PROVINCIAL), | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => { |   const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => { | ||||||
|     const bancadaActual = bancadas.find(b => b.id === bancadaId); |     const bancadaActual = bancadas.find(b => b.id === bancadaId); | ||||||
|     if (!bancadaActual) return; |     if (!bancadaActual) return; | ||||||
| 
 | 
 | ||||||
|     // Si se desasigna el partido (vacante), también se limpia el ocupante
 |  | ||||||
|     const payload: UpdateBancadaData = { |     const payload: UpdateBancadaData = { | ||||||
|       agrupacionPoliticaId: nuevaAgrupacionId, |       agrupacionPoliticaId: nuevaAgrupacionId, | ||||||
|       nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null, |       nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null, | ||||||
| @@ -38,7 +41,7 @@ export const BancasManager = () => { | |||||||
| 
 | 
 | ||||||
|     try { |     try { | ||||||
|       await updateBancada(bancadaId, payload); |       await updateBancada(bancadaId, payload); | ||||||
|       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab] }); |       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL] }); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       alert("Error al guardar el cambio de agrupación."); |       alert("Error al guardar el cambio de agrupación."); | ||||||
|     } |     } | ||||||
| @@ -49,12 +52,12 @@ export const BancasManager = () => { | |||||||
|     setModalVisible(true); |     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 ( |   return ( | ||||||
|     <div className="admin-module"> |     <div className="admin-module"> | ||||||
|       <h2>Gestión de Ocupación de Bancas</h2> |       <h3>Gestión de Bancas (Provinciales)</h3> | ||||||
|       <p>Asigne a cada banca física un partido político y, opcionalmente, los datos de la persona que la ocupa.</p> |       <p>Asigne partidos y ocupantes a las bancas de la legislatura provincial.</p> | ||||||
| 
 | 
 | ||||||
|       <div className="chamber-tabs"> |       <div className="chamber-tabs"> | ||||||
|         {camaras.map(camara => ( |         {camaras.map(camara => ( | ||||||
| @@ -63,7 +66,7 @@ export const BancasManager = () => { | |||||||
|             className={activeTab === camara ? 'active' : ''} |             className={activeTab === camara ? 'active' : ''} | ||||||
|             onClick={() => setActiveTab(camara)} |             onClick={() => setActiveTab(camara)} | ||||||
|           > |           > | ||||||
|             {camara === 'diputados' ? 'Cámara de Diputados' : 'Cámara de Senadores'} |             {camara === 'diputados' ? 'Diputados Provinciales (92)' : 'Senadores Provinciales (46)'} | ||||||
|           </button> |           </button> | ||||||
|         ))} |         ))} | ||||||
|       </div> |       </div> | ||||||
| @@ -81,32 +84,19 @@ export const BancasManager = () => { | |||||||
|           <tbody> |           <tbody> | ||||||
|             {bancadas.map((bancada) => ( |             {bancadas.map((bancada) => ( | ||||||
|               <tr key={bancada.id}> |               <tr key={bancada.id}> | ||||||
|                 {/* Usamos el NumeroBanca para la etiqueta visual */} |                 <td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td> | ||||||
|                 <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> |                 <td> | ||||||
|                   <select |                   <select | ||||||
|                     value={bancada.agrupacionPoliticaId || ''} |                     value={bancada.agrupacionPoliticaId || ''} | ||||||
|                     onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)} |                     onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)} | ||||||
|                   > |                   > | ||||||
|                     <option value="">-- Vacante --</option> |                     <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> |                   </select> | ||||||
|                 </td> |                 </td> | ||||||
|                 <td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td> |                 <td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td> | ||||||
|                 <td> |                 <td> | ||||||
|                   <button |                   <button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}> | ||||||
|                     // El botón se habilita solo si hay un partido asignado a la banca
 |  | ||||||
|                     disabled={!bancada.agrupacionPoliticaId} |  | ||||||
|                     onClick={() => handleOpenModal(bancada)} |  | ||||||
|                   > |  | ||||||
|                     Editar Ocupante |                     Editar Ocupante | ||||||
|                   </button> |                   </button> | ||||||
|                 </td> |                 </td> | ||||||
| @@ -1,96 +1,115 @@ | |||||||
| // src/components/CandidatoOverridesManager.tsx | // src/components/CandidatoOverridesManager.tsx | ||||||
|  |  | ||||||
| import { useState, useMemo, useEffect } from 'react'; | import { useState, useMemo, useEffect } from 'react'; | ||||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||||
| import Select from 'react-select'; | import Select from 'react-select'; | ||||||
| import { getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService'; | import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService'; | ||||||
| import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride } from '../types'; | import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types'; | ||||||
|  | import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias'; | ||||||
|  |  | ||||||
| const CATEGORIAS_OPTIONS = [ | const ELECCION_OPTIONS = [ | ||||||
|     { value: 5, label: 'Senadores' }, |     { value: 0, label: 'General (Todas las elecciones)' }, | ||||||
|     { value: 6, label: 'Diputados' }, |     { value: 2, label: 'Elecciones Nacionales' }, | ||||||
|     { value: 7, label: 'Concejales' } |     { 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 = () => { | export const CandidatoOverridesManager = () => { | ||||||
|     const queryClient = useQueryClient(); |     const queryClient = useQueryClient(); | ||||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); |  | ||||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); |  | ||||||
|     const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ queryKey: ['candidatos'], queryFn: getCandidatos }); |  | ||||||
|  |  | ||||||
|  |     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 [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); |     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null); |  | ||||||
|     const [nombreCandidato, setNombreCandidato] = useState(''); |     const [nombreCandidato, setNombreCandidato] = useState(''); | ||||||
|  |  | ||||||
|     const municipioOptions = useMemo(() =>  |     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||||
|         // Añadimos la opción "General" que representará un ámbito nulo |     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||||
|         [{ value: 'general', label: 'General (Todos los Municipios)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))] |     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||||
|     , [municipios]); |  | ||||||
|      |     const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ | ||||||
|     const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]); |         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(() => { |     const currentCandidato = useMemo(() => { | ||||||
|         if (!selectedAgrupacion || !selectedCategoria) return ''; |         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||||
|          |         const ambitoId = getAmbitoId(); | ||||||
|         // Determina si estamos buscando un override general (null) o específico (ID numérico) |         return candidatos.find(c => | ||||||
|         const ambitoIdBuscado = selectedMunicipio?.value === 'general' ? null : (selectedMunicipio ? parseInt(selectedMunicipio.value) : undefined); |             c.eleccionId === selectedEleccion.value && | ||||||
|  |             c.ambitoGeograficoId === ambitoId && | ||||||
|         // Si no se ha seleccionado un municipio, no buscamos nada |             c.agrupacionPoliticaId === selectedAgrupacion.id && | ||||||
|         if (ambitoIdBuscado === undefined) return ''; |  | ||||||
|  |  | ||||||
|         return candidatos.find(c =>  |  | ||||||
|             c.ambitoGeograficoId === ambitoIdBuscado &&  |  | ||||||
|             c.agrupacionPoliticaId === selectedAgrupacion.value && |  | ||||||
|             c.categoriaId === selectedCategoria.value |             c.categoriaId === selectedCategoria.value | ||||||
|         )?.nombreCandidato || ''; |         )?.nombreCandidato || ''; | ||||||
|     }, [candidatos, selectedMunicipio, selectedAgrupacion, selectedCategoria]); |     }, [candidatos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||||
|      |  | ||||||
|     useEffect(() => { setNombreCandidato(currentCandidato) }, [currentCandidato]); |     useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]); | ||||||
|  |  | ||||||
|     const handleSave = async () => { |     const handleSave = async () => { | ||||||
|         if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return; |         if (!selectedAgrupacion || !selectedCategoria) return; | ||||||
|  |  | ||||||
|         const ambitoIdParaEnviar = selectedMunicipio.value === 'general'  |  | ||||||
|             ? null  |  | ||||||
|             : parseInt(selectedMunicipio.value); |  | ||||||
|  |  | ||||||
|         const newCandidatoEntry: CandidatoOverride = { |         const newCandidatoEntry: CandidatoOverride = { | ||||||
|             id: 0, // El backend no lo necesita para el upsert |             id: 0, | ||||||
|             agrupacionPoliticaId: selectedAgrupacion.value, |             eleccionId: selectedEleccion.value, | ||||||
|  |             agrupacionPoliticaId: selectedAgrupacion.id, | ||||||
|             categoriaId: selectedCategoria.value, |             categoriaId: selectedCategoria.value, | ||||||
|             ambitoGeograficoId: ambitoIdParaEnviar, |             ambitoGeograficoId: getAmbitoId(), | ||||||
|             nombreCandidato: nombreCandidato || null |             nombreCandidato: nombreCandidato.trim() || null | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             await updateCandidatos([newCandidatoEntry]); |             await updateCandidatos([newCandidatoEntry]); | ||||||
|             queryClient.invalidateQueries({ queryKey: ['candidatos'] }); |             queryClient.invalidateQueries({ queryKey: ['allCandidatos'] }); | ||||||
|             alert('Override de candidato guardado.'); |             alert('Override de candidato guardado.'); | ||||||
|         } catch (error) { |         } catch (error) { | ||||||
|             console.error(error); |             console.error(error); | ||||||
|             alert('Error al guardar el override del candidato.'); |             alert('Error al guardar el override del candidato.'); | ||||||
|         } |         } | ||||||
|     }; |     }; | ||||||
|      |  | ||||||
|     return ( |     return ( | ||||||
|         <div className="admin-module"> |         <div className="admin-module"> | ||||||
|             <h3>Overrides de Nombres de Candidatos</h3> |             <h3>Overrides de Nombres de Candidatos</h3> | ||||||
|             <p>Configure un nombre de candidato específico para un partido, categoría y municipio (o general).</p> |             <p>Configure un nombre de candidato específico para un partido en un contexto determinado.</p> | ||||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}> |             <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 }}> |                 <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 (Opcional)</label> |  | ||||||
|                     <Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="General..."/> |  | ||||||
|                 </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>Nombre del Candidato</label> |                     <label>Nombre del Candidato</label> | ||||||
|                     <input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} /> |                     <input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} /> | ||||||
|                 </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,43 +1,89 @@ | |||||||
| // src/components/DashboardPage.tsx | // src/components/DashboardPage.tsx | ||||||
| import { useAuth } from '../context/AuthContext'; | import { useAuth } from '../context/AuthContext'; | ||||||
| import { AgrupacionesManager } from './AgrupacionesManager'; | import { AgrupacionesManager } from './AgrupacionesManager'; | ||||||
| import { OrdenDiputadosManager } from './OrdenDiputadosManager'; | //import { OrdenDiputadosManager } from './OrdenDiputadosManager'; | ||||||
| import { OrdenSenadoresManager } from './OrdenSenadoresManager'; | //import { OrdenSenadoresManager } from './OrdenSenadoresManager'; | ||||||
| import { ConfiguracionGeneral } from './ConfiguracionGeneral'; | //import { ConfiguracionGeneral } from './ConfiguracionGeneral'; | ||||||
| import { BancasManager } from './BancasManager'; |  | ||||||
| import { LogoOverridesManager } from './LogoOverridesManager'; | import { LogoOverridesManager } from './LogoOverridesManager'; | ||||||
| import { CandidatoOverridesManager } from './CandidatoOverridesManager'; | import { CandidatoOverridesManager } from './CandidatoOverridesManager'; | ||||||
| import { WorkerManager } from './WorkerManager'; | 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 = () => { | export const DashboardPage = () => { | ||||||
|     const { logout } = useAuth(); |     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 ( |     return ( | ||||||
|         <div style={{ padding: '1rem 2rem' }}> |         <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> |                 <h1>Panel de Administración Electoral</h1> | ||||||
|                 <button onClick={logout}>Cerrar Sesión</button> |                 <button onClick={logout}>Cerrar Sesión</button> | ||||||
|             </header> |             </header> | ||||||
|  |              | ||||||
|             <main style={{ marginTop: '2rem' }}> |             <main style={{ marginTop: '2rem' }}> | ||||||
|                 <AgrupacionesManager /> |  | ||||||
|                     <div style={{ flex: '1 1 800px' }}> |                 <div style={sectionStyle}> | ||||||
|                         <LogoOverridesManager /> |                     <h2 style={sectionTitleStyle}>Configuración Global</h2> | ||||||
|                     </div> |                     <AgrupacionesManager /> | ||||||
|                     <div style={{ flex: '1 1 800px' }}> |                     <LogoOverridesManager /> | ||||||
|                         <CandidatoOverridesManager /> |                     <CandidatoOverridesManager /> | ||||||
|                     </div> |                 </div> | ||||||
|                 <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}> |                  | ||||||
|                     <div style={{ flex: '1 1 400px' }}> |                 <div style={sectionStyle}> | ||||||
|                         <OrdenDiputadosManager /> |                     <h2 style={sectionTitleStyle}>Gestión de Elecciones Nacionales</h2> | ||||||
|                     </div> |                     <ConfiguracionNacional /> | ||||||
|                     <div style={{ flex: '1 1 400px' }}> |                     <BancasPreviasManager /> | ||||||
|                         <OrdenSenadoresManager /> |                     <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}> | ||||||
|                     </div> |                         <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> |                 </div> | ||||||
|                 <ConfiguracionGeneral /> |  | ||||||
|                 <BancasManager /> |  | ||||||
|                 <hr style={{ margin: '2rem 0' }}/> |  | ||||||
|                 <WorkerManager /> |  | ||||||
|             </main> |             </main> | ||||||
|         </div> |         </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,83 +2,119 @@ | |||||||
| import { useState, useMemo, useEffect } from 'react'; | import { useState, useMemo, useEffect } from 'react'; | ||||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||||
| import Select from 'react-select'; | import Select from 'react-select'; | ||||||
| import { getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService'; | import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService'; | ||||||
| import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria } from '../types'; | 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 ELECCION_OPTIONS = [ | ||||||
| const CATEGORIAS_OPTIONS = [ |     { value: 0, label: 'General (Todas las elecciones)' }, | ||||||
|     { value: 5, label: 'Senadores' }, |     { value: 2, label: 'Elecciones Nacionales' }, | ||||||
|     { value: 6, label: 'Diputados' }, |     { value: 1, label: 'Elecciones Provinciales' } | ||||||
|     { value: 7, label: 'Concejales' } | ]; | ||||||
|  |  | ||||||
|  | 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 = () => { | export const LogoOverridesManager = () => { | ||||||
|     const queryClient = useQueryClient(); |     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 [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); |     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null); |  | ||||||
|     const [logoUrl, setLogoUrl] = useState(''); |     const [logoUrl, setLogoUrl] = useState(''); | ||||||
|  |  | ||||||
|     const municipioOptions = useMemo(() =>  |     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||||
|         [{ value: 'general', label: 'General (Todas las secciones)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))] |     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||||
|     , [municipios]); |     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||||
|     const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]); |  | ||||||
|  |     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(() => { |     const currentLogo = useMemo(() => { | ||||||
|         // La búsqueda ahora depende de los 3 selectores |         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||||
|         if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return ''; |         const ambitoId = getAmbitoId(); | ||||||
|         return logos.find(l =>  |  | ||||||
|             l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&  |         return logos.find(l => | ||||||
|             l.agrupacionPoliticaId === selectedAgrupacion.value && |             l.eleccionId === selectedEleccion.value && | ||||||
|  |             l.ambitoGeograficoId === ambitoId && | ||||||
|  |             l.agrupacionPoliticaId === selectedAgrupacion.id && | ||||||
|             l.categoriaId === selectedCategoria.value |             l.categoriaId === selectedCategoria.value | ||||||
|         )?.logoUrl || ''; |         )?.logoUrl || ''; | ||||||
|     }, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]); |     }, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||||
|      |  | ||||||
|     useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]); |     useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]); | ||||||
|  |  | ||||||
|     const handleSave = async () => { |     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 = { |         const newLogoEntry: LogoAgrupacionCategoria = { | ||||||
|             id: 0, |             id: 0, | ||||||
|             agrupacionPoliticaId: selectedAgrupacion.value, |             eleccionId: selectedEleccion.value, | ||||||
|  |             agrupacionPoliticaId: selectedAgrupacion.id, | ||||||
|             categoriaId: selectedCategoria.value, |             categoriaId: selectedCategoria.value, | ||||||
|             ambitoGeograficoId: parseInt(selectedMunicipio.value), |             ambitoGeograficoId: getAmbitoId(), | ||||||
|             logoUrl: logoUrl || null |             logoUrl: logoUrl.trim() || null | ||||||
|         }; |         }; | ||||||
|         try { |         try { | ||||||
|             await updateLogos([newLogoEntry]); |             await updateLogos([newLogoEntry]); | ||||||
|             queryClient.invalidateQueries({ queryKey: ['logos'] }); |             queryClient.invalidateQueries({ queryKey: ['allLogos'] }); | ||||||
|             alert('Override de logo guardado.'); |             alert('Override de logo guardado.'); | ||||||
|         } catch { alert('Error al guardar.'); } |         } catch { alert('Error al guardar.'); } | ||||||
|     }; |     }; | ||||||
|      |  | ||||||
|     return ( |     return ( | ||||||
|         <div className="admin-module"> |         <div className="admin-module"> | ||||||
|             <h3>Overrides de Logos por Municipio y Categoría</h3> |             <h3>Overrides de Logos</h3> | ||||||
|             <p>Configure una imagen específica para un partido en un municipio y categoría determinados.</p> |             <p>Configure una imagen específica para un partido en un contexto determinado.</p> | ||||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end' }}> |             <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 }}> |                 <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> |                     <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> |                 </div> | ||||||
|                 <button onClick={handleSave} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria}>Guardar</button> |                 <button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos | |||||||
| const updateOrdenDiputadosApi = async (ids: string[]) => { | const updateOrdenDiputadosApi = async (ids: string[]) => { | ||||||
|   const token = localStorage.getItem('admin-jwt-token'); |   const token = localStorage.getItem('admin-jwt-token'); | ||||||
|   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-diputados', { |   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-diputados', { | ||||||
|       method: 'PUT', |     method: 'PUT', | ||||||
|       headers: { |     headers: { | ||||||
|           'Content-Type': 'application/json', |       'Content-Type': 'application/json', | ||||||
|           'Authorization': `Bearer ${token}` |       'Authorization': `Bearer ${token}` | ||||||
|       }, |     }, | ||||||
|       body: JSON.stringify(ids) |     body: JSON.stringify(ids) | ||||||
|   }); |   }); | ||||||
|   if (!response.ok) { |   if (!response.ok) { | ||||||
|     throw new Error("Failed to save Diputados order"); |     throw new Error("Failed to save Diputados order"); | ||||||
| @@ -38,77 +38,77 @@ const updateOrdenDiputadosApi = async (ids: string[]) => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| export const OrdenDiputadosManager = () => { | export const OrdenDiputadosManager = () => { | ||||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); |   const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||||
|     const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
|     const sensors = useSensors( |   const sensors = useSensors( | ||||||
|       useSensor(PointerSensor), |     useSensor(PointerSensor), | ||||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) |     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||||
|     ); |   ); | ||||||
|  |  | ||||||
|     useEffect(() => { |   useEffect(() => { | ||||||
|         const fetchAndSortAgrupaciones = async () => { |     const fetchAndSortAgrupaciones = async () => { | ||||||
|             setLoading(true); |       setLoading(true); | ||||||
|             try { |       try { | ||||||
|                 const data = await getAgrupaciones(); |         const data = await getAgrupaciones(); | ||||||
|                 // Ordenar por el orden de Diputados. Los nulos van al final. |         // Ordenar por el orden de Diputados. Los nulos van al final. | ||||||
|                 data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999)); |         data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999)); | ||||||
|                 setAgrupaciones(data); |         setAgrupaciones(data); | ||||||
|             } catch (error) { |       } catch (error) { | ||||||
|                 console.error("Failed to fetch agrupaciones for Diputados:", error); |         console.error("Failed to fetch agrupaciones for Diputados:", error); | ||||||
|             } finally { |       } finally { | ||||||
|                 setLoading(false); |         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); |  | ||||||
|         }); |  | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|  |     fetchAndSortAgrupaciones(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|     const handleSaveOrder = async () => { |   const handleDragEnd = (event: DragEndEvent) => { | ||||||
|         const idsOrdenados = agrupaciones.map(a => a.id); |     const { active, over } = event; | ||||||
|         try { |     if (over && active.id !== over.id) { | ||||||
|             await updateOrdenDiputadosApi(idsOrdenados); |       setAgrupaciones((items) => { | ||||||
|             alert('Orden de Diputados guardado con éxito!'); |         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||||
|         } catch (error) { |         const newIndex = items.findIndex((item) => item.id === over.id); | ||||||
|             alert('Error al guardar el orden de Diputados.'); |         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 ( |   if (loading) return <p>Cargando orden de Diputados...</p>; | ||||||
|         <div className="admin-module"> |  | ||||||
|             <h3>Ordenar Agrupaciones (Diputados)</h3> |   return ( | ||||||
|             <p>Arrastre para reordenar.</p> |     <div className="admin-module"> | ||||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> |       <h3>Ordenar Agrupaciones (Diputados)</h3> | ||||||
|             <DndContext |       <p>Arrastre para reordenar.</p> | ||||||
|               sensors={sensors} |       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||||
|               collisionDetection={closestCenter} |       <DndContext | ||||||
|               onDragEnd={handleDragEnd} |         sensors={sensors} | ||||||
|             > |         collisionDetection={closestCenter} | ||||||
|               <SortableContext |         onDragEnd={handleDragEnd} | ||||||
|                 items={agrupaciones.map(a => a.id)} |       > | ||||||
|                 strategy={horizontalListSortingStrategy} |         <SortableContext | ||||||
|               > |           items={agrupaciones.map(a => a.id)} | ||||||
|                 <ul className="sortable-list-horizontal"> |           strategy={horizontalListSortingStrategy} | ||||||
|                   {agrupaciones.map(agrupacion => ( |         > | ||||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> |           <ul className="sortable-list-horizontal"> | ||||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} |             {agrupaciones.map(agrupacion => ( | ||||||
|                     </SortableItem> |               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||||
|                   ))} |                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||||
|                 </ul> |               </SortableItem> | ||||||
|               </SortableContext> |             ))} | ||||||
|             </DndContext> |           </ul> | ||||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button> |         </SortableContext> | ||||||
|         </div> |       </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 updateOrdenSenadoresApi = async (ids: string[]) => { | ||||||
|   const token = localStorage.getItem('admin-jwt-token'); |   const token = localStorage.getItem('admin-jwt-token'); | ||||||
|   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-senadores', { |   const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-senadores', { | ||||||
|       method: 'PUT', |     method: 'PUT', | ||||||
|       headers: { |     headers: { | ||||||
|           'Content-Type': 'application/json', |       'Content-Type': 'application/json', | ||||||
|           'Authorization': `Bearer ${token}` |       'Authorization': `Bearer ${token}` | ||||||
|       }, |     }, | ||||||
|       body: JSON.stringify(ids) |     body: JSON.stringify(ids) | ||||||
|   }); |   }); | ||||||
|   if (!response.ok) { |   if (!response.ok) { | ||||||
|     throw new Error("Failed to save Senadores order"); |     throw new Error("Failed to save Senadores order"); | ||||||
| @@ -38,77 +38,77 @@ const updateOrdenSenadoresApi = async (ids: string[]) => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| export const OrdenSenadoresManager = () => { | export const OrdenSenadoresManager = () => { | ||||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); |   const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||||
|     const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
|     const sensors = useSensors( |   const sensors = useSensors( | ||||||
|       useSensor(PointerSensor), |     useSensor(PointerSensor), | ||||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) |     useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||||
|     ); |   ); | ||||||
|  |  | ||||||
|     useEffect(() => { |   useEffect(() => { | ||||||
|         const fetchAndSortAgrupaciones = async () => { |     const fetchAndSortAgrupaciones = async () => { | ||||||
|             setLoading(true); |       setLoading(true); | ||||||
|             try { |       try { | ||||||
|                 const data = await getAgrupaciones(); |         const data = await getAgrupaciones(); | ||||||
|                 // Ordenar por el orden de Senadores. Los nulos van al final. |         // Ordenar por el orden de Senadores. Los nulos van al final. | ||||||
|                 data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999)); |         data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999)); | ||||||
|                 setAgrupaciones(data); |         setAgrupaciones(data); | ||||||
|             } catch (error) { |       } catch (error) { | ||||||
|                 console.error("Failed to fetch agrupaciones for Senadores:", error); |         console.error("Failed to fetch agrupaciones for Senadores:", error); | ||||||
|             } finally { |       } finally { | ||||||
|                 setLoading(false); |         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); |  | ||||||
|         }); |  | ||||||
|       } |       } | ||||||
|     }; |     }; | ||||||
|  |     fetchAndSortAgrupaciones(); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|     const handleSaveOrder = async () => { |   const handleDragEnd = (event: DragEndEvent) => { | ||||||
|         const idsOrdenados = agrupaciones.map(a => a.id); |     const { active, over } = event; | ||||||
|         try { |     if (over && active.id !== over.id) { | ||||||
|             await updateOrdenSenadoresApi(idsOrdenados); |       setAgrupaciones((items) => { | ||||||
|             alert('Orden de Senadores guardado con éxito!'); |         const oldIndex = items.findIndex((item) => item.id === active.id); | ||||||
|         } catch (error) { |         const newIndex = items.findIndex((item) => item.id === over.id); | ||||||
|             alert('Error al guardar el orden de Senadores.'); |         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 ( |   if (loading) return <p>Cargando orden de Senadores...</p>; | ||||||
|         <div className="admin-module"> |  | ||||||
|             <h3>Ordenar Agrupaciones (Senado)</h3> |   return ( | ||||||
|             <p>Arrastre para reordenar.</p> |     <div className="admin-module"> | ||||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> |       <h3>Ordenar Agrupaciones (Senado)</h3> | ||||||
|             <DndContext |       <p>Arrastre para reordenar.</p> | ||||||
|               sensors={sensors} |       <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||||
|               collisionDetection={closestCenter} |       <DndContext | ||||||
|               onDragEnd={handleDragEnd} |         sensors={sensors} | ||||||
|             > |         collisionDetection={closestCenter} | ||||||
|               <SortableContext |         onDragEnd={handleDragEnd} | ||||||
|                 items={agrupaciones.map(a => a.id)} |       > | ||||||
|                 strategy={horizontalListSortingStrategy} |         <SortableContext | ||||||
|               > |           items={agrupaciones.map(a => a.id)} | ||||||
|                 <ul className="sortable-list-horizontal"> |           strategy={horizontalListSortingStrategy} | ||||||
|                   {agrupaciones.map(agrupacion => ( |         > | ||||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> |           <ul className="sortable-list-horizontal"> | ||||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} |             {agrupaciones.map(agrupacion => ( | ||||||
|                     </SortableItem> |               <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||||
|                   ))} |                 {`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`} | ||||||
|                 </ul> |               </SortableItem> | ||||||
|               </SortableContext> |             ))} | ||||||
|             </DndContext> |           </ul> | ||||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button> |         </SortableContext> | ||||||
|         </div> |       </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> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										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,11 +1,14 @@ | |||||||
| // src/services/apiService.ts | // src/services/apiService.ts | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import { triggerLogout } from '../context/authUtils'; | import { triggerLogout } from '../context/authUtils'; | ||||||
| import type { CandidatoOverride, AgrupacionPolitica, UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, MunicipioSimple } from '../types'; | import type { | ||||||
|  |   CandidatoOverride, AgrupacionPolitica, | ||||||
|  |   UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, | ||||||
|  |   MunicipioSimple, BancaPrevia, ProvinciaSimple | ||||||
|  | } from '../types'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * URL base para las llamadas a la API. |  * URL base para las llamadas a la API. | ||||||
|  * Se usa para construir las URLs más específicas. |  | ||||||
|  */ |  */ | ||||||
| const API_URL_BASE = import.meta.env.DEV | const API_URL_BASE = import.meta.env.DEV | ||||||
|   ? 'http://localhost:5217/api' |   ? 'http://localhost:5217/api' | ||||||
| @@ -21,13 +24,19 @@ export const AUTH_API_URL = `${API_URL_BASE}/auth`; | |||||||
|  */ |  */ | ||||||
| export const ADMIN_API_URL = `${API_URL_BASE}/admin`; | export const ADMIN_API_URL = `${API_URL_BASE}/admin`; | ||||||
|  |  | ||||||
|  | // Cliente de API para endpoints de administración (requiere token) | ||||||
| const adminApiClient = axios.create({ | const adminApiClient = axios.create({ | ||||||
|   baseURL: ADMIN_API_URL, |   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( | adminApiClient.interceptors.request.use( | ||||||
|   (config) => { |   (config) => { | ||||||
|     const token = localStorage.getItem('admin-jwt-token'); |     const token = localStorage.getItem('admin-jwt-token'); | ||||||
| @@ -39,7 +48,6 @@ adminApiClient.interceptors.request.use( | |||||||
|   (error) => Promise.reject(error) |   (error) => Promise.reject(error) | ||||||
| ); | ); | ||||||
|  |  | ||||||
| // Interceptor de Respuestas: Maneja la expiración del token (error 401) |  | ||||||
| adminApiClient.interceptors.response.use( | adminApiClient.interceptors.response.use( | ||||||
|   (response) => response, |   (response) => response, | ||||||
|   (error) => { |   (error) => { | ||||||
| @@ -51,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 --- | // --- SERVICIOS DE API --- | ||||||
|  |  | ||||||
| // 1. Autenticación | // 1. Autenticación | ||||||
| @@ -66,7 +100,7 @@ export const loginUser = async (credentials: LoginCredentials): Promise<string | | |||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // 2. Agrupaciones Políticas | // 2. Agrupaciones | ||||||
| export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => { | export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => { | ||||||
|   const response = await adminApiClient.get('/agrupaciones'); |   const response = await adminApiClient.get('/agrupaciones'); | ||||||
|   return response.data; |   return response.data; | ||||||
| @@ -77,14 +111,14 @@ export const updateAgrupacion = async (id: string, data: UpdateAgrupacionData): | |||||||
| }; | }; | ||||||
|  |  | ||||||
| // 3. Ordenamiento de Agrupaciones | // 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); |   await adminApiClient.put(`/agrupaciones/orden-${camara}`, ids); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // 4. Gestión de Bancas y Ocupantes | // 4. Gestión de Bancas | ||||||
| export const getBancadas = async (camara: 'diputados' | 'senadores'): Promise<Bancada[]> => { | export const getBancadas = async (camara: 'diputados' | 'senadores', eleccionId: number): Promise<Bancada[]> => { | ||||||
|   const camaraId = camara === 'diputados' ? 0 : 1; |   const camaraId = (camara === 'diputados') ? 0 : 1; | ||||||
|   const response = await adminApiClient.get(`/bancadas/${camaraId}`); |   const response = await adminApiClient.get(`/bancadas/${camaraId}?eleccionId=${eleccionId}`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -111,38 +145,64 @@ export const updateConfiguracion = async (data: Record<string, string>): Promise | |||||||
|   await adminApiClient.put('/configuracion', data); |   await adminApiClient.put('/configuracion', data); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getLogos = async (): Promise<LogoAgrupacionCategoria[]> => { | // 6. Logos y Candidatos | ||||||
|   const response = await adminApiClient.get('/logos'); | export const getLogos = async (eleccionId: number): Promise<LogoAgrupacionCategoria[]> => { | ||||||
|  |   const response = await adminApiClient.get(`/logos?eleccionId=${eleccionId}`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const updateLogos = async (data: LogoAgrupacionCategoria[]): Promise<void> => { | export const updateLogos = async (data: LogoAgrupacionCategoria[]): Promise<void> => { | ||||||
|   await adminApiClient.put('/logos', data); |   await adminApiClient.put('/logos', data); | ||||||
| }; | }; | ||||||
|  | export const getCandidatos = async (eleccionId: number): Promise<CandidatoOverride[]> => { | ||||||
| export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => { |   const response = await adminApiClient.get(`/candidatos?eleccionId=${eleccionId}`); | ||||||
|   // 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; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // 6. Overrides de Candidatos |  | ||||||
| export const getCandidatos = async (): Promise<CandidatoOverride[]> => { |  | ||||||
|   const response = await adminApiClient.get('/candidatos'); |  | ||||||
|   return response.data; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => { | export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => { | ||||||
|   await adminApiClient.put('/candidatos', data); |   await adminApiClient.put('/candidatos', data); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| // 7. Gestión de Logging | // 7. Catálogos | ||||||
| export interface UpdateLoggingLevelData { | export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => { | ||||||
|   level: string; |   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; | ||||||
| } | } | ||||||
|  |  | ||||||
| export const updateLoggingLevel = async (data: UpdateLoggingLevelData): Promise<void> => { | // Servicio para crear una nueva agrupación | ||||||
|   // Este endpoint es específico, no es parte de la configuración general | export const createAgrupacion = async (data: CreateAgrupacionData): Promise<AgrupacionPolitica> => { | ||||||
|   await adminApiClient.put(`/logging-level`, data); |   const response = await adminApiClient.post('/agrupaciones', data); | ||||||
|  |   return response.data; | ||||||
| }; | }; | ||||||
| @@ -8,6 +8,8 @@ export interface AgrupacionPolitica { | |||||||
|   color: string | null; |   color: string | null; | ||||||
|   ordenDiputados: number | null; |   ordenDiputados: number | null; | ||||||
|   ordenSenadores: number | null; |   ordenSenadores: number | null; | ||||||
|  |   ordenDiputadosNacionales: number | null; | ||||||
|  |   ordenSenadoresNacionales: number | null; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface UpdateAgrupacionData { | export interface UpdateAgrupacionData { | ||||||
| @@ -30,9 +32,9 @@ export interface OcupanteBanca { | |||||||
|   periodo: string | null; |   periodo: string | null; | ||||||
| } | } | ||||||
|  |  | ||||||
| // Nueva interfaz para la Bancada |  | ||||||
| export interface Bancada { | export interface Bancada { | ||||||
|   id: number; |   id: number; | ||||||
|  |   eleccionId: number; // Clave para diferenciar provinciales de nacionales | ||||||
|   camara: TipoCamaraValue; |   camara: TipoCamaraValue; | ||||||
|   numeroBanca: number; |   numeroBanca: number; | ||||||
|   agrupacionPoliticaId: string | null; |   agrupacionPoliticaId: string | null; | ||||||
| @@ -40,18 +42,33 @@ export interface Bancada { | |||||||
|   ocupante: OcupanteBanca | null; |   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 { | export interface LogoAgrupacionCategoria { | ||||||
|     id: number; |     id: number; | ||||||
|  |     eleccionId: number; // Clave para diferenciar | ||||||
|     agrupacionPoliticaId: string; |     agrupacionPoliticaId: string; | ||||||
|     categoriaId: number; |     categoriaId: number | null; | ||||||
|     logoUrl: string | null; |     logoUrl: string | null; | ||||||
|     ambitoGeograficoId: number | 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 { | export interface CandidatoOverride { | ||||||
|   id: number; |   id: number; | ||||||
|  |   eleccionId: number; // Clave para diferenciar | ||||||
|   agrupacionPoliticaId: string; |   agrupacionPoliticaId: string; | ||||||
|   categoriaId: number; |   categoriaId: number; | ||||||
|   ambitoGeograficoId: number | null; |   ambitoGeograficoId: number | null; | ||||||
|   | |||||||
							
								
								
									
										573
									
								
								Elecciones-Web/frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -18,13 +18,20 @@ | |||||||
|     "axios": "^1.11.0", |     "axios": "^1.11.0", | ||||||
|     "d3-geo": "^3.1.1", |     "d3-geo": "^3.1.1", | ||||||
|     "d3-shape": "^3.2.0", |     "d3-shape": "^3.2.0", | ||||||
|  |     "highcharts": "^12.4.0", | ||||||
|  |     "highcharts-react-official": "^3.2.2", | ||||||
|     "react": "^19.1.1", |     "react": "^19.1.1", | ||||||
|  |     "react-circular-progressbar": "^2.2.0", | ||||||
|     "react-dom": "^19.1.1", |     "react-dom": "^19.1.1", | ||||||
|  |     "react-hot-toast": "^2.6.0", | ||||||
|  |     "react-icons": "^5.5.0", | ||||||
|     "react-pdf": "^10.1.0", |     "react-pdf": "^10.1.0", | ||||||
|     "react-select": "^5.10.2", |     "react-select": "^5.10.2", | ||||||
|     "react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support", |     "react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support", | ||||||
|     "react-tooltip": "^5.29.1", |     "react-tooltip": "^5.29.1", | ||||||
|     "topojson-client": "^3.1.0" |     "swiper": "^12.0.2", | ||||||
|  |     "topojson-client": "^3.1.0", | ||||||
|  |     "vite-plugin-svgr": "^4.5.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.33.0", |     "@eslint/js": "^9.33.0", | ||||||
|   | |||||||
							
								
								
									
										156
									
								
								Elecciones-Web/frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -4,9 +4,16 @@ | |||||||
|   // El dominio donde se alojan los widgets |   // El dominio donde se alojan los widgets | ||||||
|   const WIDGETS_HOST = 'https://elecciones2025.eldia.com'; |   const WIDGETS_HOST = 'https://elecciones2025.eldia.com'; | ||||||
|  |  | ||||||
|   // Función para cargar dinámicamente un script |   // 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) { |   function loadScript(src) { | ||||||
|     return new Promise((resolve, reject) => { |     return new Promise((resolve, reject) => { | ||||||
|  |       if ([...document.scripts].some(s => s.src === src)) return resolve(); | ||||||
|       const script = document.createElement('script'); |       const script = document.createElement('script'); | ||||||
|       script.type = 'module'; |       script.type = 'module'; | ||||||
|       script.src = src; |       script.src = src; | ||||||
| @@ -16,73 +23,116 @@ | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Función para cargar dinámicamente una hoja de estilos |   // Función para cargar dinámicamente una hoja de estilos (evita duplicados) | ||||||
|   function loadCSS(href) { |   function loadCSS(href) { | ||||||
|  |     if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(l => l.href === href)) return; | ||||||
|     const link = document.createElement('link'); |     const link = document.createElement('link'); | ||||||
|     link.rel = 'stylesheet'; |     link.rel = 'stylesheet'; | ||||||
|     link.href = href; |     link.href = href; | ||||||
|     document.head.appendChild(link); |     document.head.appendChild(link); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Función principal |   // 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() { |   async function initWidgets() { | ||||||
|     try { |     try { | ||||||
|       // 1. Obtener el manifest.json para saber los nombres de archivo actuales |       await ensureAssetsFromManifest(); | ||||||
|       const response = await fetch(`${WIDGETS_HOST}/manifest.json`); |       renderWidgetsOnPage(); | ||||||
|       if (!response.ok) { |  | ||||||
|         throw new Error('No se pudo cargar el manifest de los widgets.'); |  | ||||||
|       } |  | ||||||
|       const manifest = await response.json(); |  | ||||||
|  |  | ||||||
|       // 2. Encontrar el punto de entrada principal (nuestro main.tsx) |  | ||||||
|       const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); |  | ||||||
|       if (!entryKey) { |  | ||||||
|         throw new Error('No se encontró el punto de entrada en el manifest.'); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       const entry = manifest[entryKey]; |  | ||||||
|       const jsUrl = `${WIDGETS_HOST}/${entry.file}`; |  | ||||||
|  |  | ||||||
|       // 3. Cargar el CSS si existe |  | ||||||
|       if (entry.css && entry.css.length > 0) { |  | ||||||
|         entry.css.forEach(cssFile => { |  | ||||||
|           const cssUrl = `${WIDGETS_HOST}/${cssFile}`; |  | ||||||
|           loadCSS(cssUrl); |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // 4. Cargar el JS principal y esperar a que esté listo |  | ||||||
|       await loadScript(jsUrl); |  | ||||||
|  |  | ||||||
|  |  | ||||||
|       // 5. Una vez cargado, llamar a la función de renderizado. |  | ||||||
|       if (window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function') { |  | ||||||
|         console.log('Bootstrap: La función render existe. Renderizando todos los widgets encontrados...'); |  | ||||||
|          |  | ||||||
|         const widgetContainers = document.querySelectorAll('[data-elecciones-widget]'); |  | ||||||
|          |  | ||||||
|         if (widgetContainers.length === 0) { |  | ||||||
|             console.warn('Bootstrap: No se encontraron contenedores de widget en la página.'); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         widgetContainers.forEach(container => { |  | ||||||
|           // 'dataset' es un objeto que contiene todos los atributos data-* |  | ||||||
|           window.EleccionesWidgets.render(container, container.dataset); |  | ||||||
|         }); |  | ||||||
|       } else { |  | ||||||
|         console.error('Bootstrap: ERROR CRÍTICO - La función render() NO SE ENCONTRÓ en window.EleccionesWidgets.'); |  | ||||||
|         console.log('Bootstrap: Contenido de window.EleccionesWidgets:', window.EleccionesWidgets); |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       console.error('Error al inicializar los widgets de elecciones:', error); |       console.error('Error al inicializar los widgets de elecciones:', error); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (document.readyState === 'loading') { // Aún cargando |   // 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); |     document.addEventListener('DOMContentLoaded', initWidgets); | ||||||
|   } else { // Ya cargado |   } else { | ||||||
|     initWidgets(); |     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 }); | ||||||
|  | })(); | ||||||
|   | |||||||
| 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,29 +1,32 @@ | |||||||
| #root { | /* src/App.css */ | ||||||
|  |  | ||||||
|  | .container-legislativas2025 { | ||||||
|   max-width: 1280px; |   max-width: 1280px; | ||||||
|   margin: 0 auto; |   margin: 0 auto; | ||||||
|   padding: 2rem; |   padding: 2rem; | ||||||
|   text-align: center; |   text-align: center; | ||||||
| } | } | ||||||
|  |  | ||||||
| @keyframes logo-spin { | @keyframes elecciones-logo-spin { | ||||||
|   from { |   from { | ||||||
|     transform: rotate(0deg); |     transform: rotate(0deg); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   to { |   to { | ||||||
|     transform: rotate(360deg); |     transform: rotate(360deg); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (prefers-reduced-motion: no-preference) { | @media (prefers-reduced-motion: no-preference) { | ||||||
|   a:nth-of-type(2) .logo { |   .container-legislativas2025 a:nth-of-type(2) .logo { | ||||||
|     animation: logo-spin infinite 20s linear; |     animation: elecciones-logo-spin infinite 20s linear; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| .card { | .container-legislativas2025 .card { | ||||||
|   padding: 2em; |   padding: 2em; | ||||||
| } | } | ||||||
|  |  | ||||||
| .read-the-docs { | .container-legislativas2025 .read-the-docs { | ||||||
|   color: #888; |   color: #888; | ||||||
| } | } | ||||||
| @@ -1,6 +1,5 @@ | |||||||
| // src/App.tsx | // src/App.tsx | ||||||
| import './App.css' | /*import { BancasWidget } from './components/BancasWidget' | ||||||
| import { BancasWidget } from './components/BancasWidget' |  | ||||||
| import { CongresoWidget } from './components/CongresoWidget' | import { CongresoWidget } from './components/CongresoWidget' | ||||||
| import MapaBsAs from './components/MapaBsAs' | import MapaBsAs from './components/MapaBsAs' | ||||||
| import { DipSenTickerWidget } from './components/DipSenTickerWidget' | import { DipSenTickerWidget } from './components/DipSenTickerWidget' | ||||||
| @@ -18,10 +17,11 @@ import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidge | |||||||
| import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget' | import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget' | ||||||
| import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget' | import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget' | ||||||
| import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget' | import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget' | ||||||
|  | */ | ||||||
| function App() { | function App() { | ||||||
|   return ( |   return ({/* | ||||||
|     <> |     <> | ||||||
|  |      | ||||||
|       <h1>Resultados Electorales - Provincia de Buenos Aires</h1> |       <h1>Resultados Electorales - Provincia de Buenos Aires</h1> | ||||||
|       <main className="space-y-6"> |       <main className="space-y-6"> | ||||||
|         <ResumenGeneralWidget /> |         <ResumenGeneralWidget /> | ||||||
| @@ -60,7 +60,7 @@ function App() { | |||||||
|         <hr className="border-gray-300" /> |         <hr className="border-gray-300" /> | ||||||
|         <ResultadosRankingMunicipioWidget /> |         <ResultadosRankingMunicipioWidget /> | ||||||
|       </main> |       </main> | ||||||
|     </> |     </>*/} | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,6 +1,13 @@ | |||||||
| // src/apiService.ts | // src/apiService.ts | ||||||
| import axios from 'axios'; | import axios from 'axios'; | ||||||
| import type { ApiResponseRankingMunicipio, ApiResponseRankingSeccion, ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, ApiResponseResultadosPorSeccion } from './types/types'; | import type { | ||||||
|  |   ApiResponseRankingMunicipio, ApiResponseRankingSeccion, | ||||||
|  |   ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, | ||||||
|  |   TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, | ||||||
|  |   ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia, | ||||||
|  |   CategoriaResumenHome, ResultadoFila, ResultadoSeccion, | ||||||
|  |   ProvinciaResumen | ||||||
|  | } from './types/types'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * URL base para las llamadas a la API. |  * URL base para las llamadas a la API. | ||||||
| @@ -73,7 +80,6 @@ export interface BancadaDetalle { | |||||||
| export interface ConfiguracionPublica { | export interface ConfiguracionPublica { | ||||||
|   TickerResultadosCantidad?: string; |   TickerResultadosCantidad?: string; | ||||||
|   ConcejalesResultadosCantidad?: string; |   ConcejalesResultadosCantidad?: string; | ||||||
|   // ... otras claves públicas que pueda añadir en el futuro |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface ResultadoDetalleSeccion { | export interface ResultadoDetalleSeccion { | ||||||
| @@ -84,14 +90,46 @@ export interface ResultadoDetalleSeccion { | |||||||
|   color: string | null; |   color: string | null; | ||||||
| } | } | ||||||
|  |  | ||||||
| export const getResumenProvincial = async (): Promise<CategoriaResumen[]> => { | export interface PartidoComposicionNacional { | ||||||
|   const response = await apiClient.get('/resultados/provincia/02'); |   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; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getBancasPorSeccion = async (seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => { | export const getBancasPorSeccion = async (eleccionId: number, seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => { | ||||||
|     const { data } = await apiClient.get(`/resultados/bancas-por-seccion/${seccionId}/${camara}`); |   const { data } = await apiClient.get(`/elecciones/${eleccionId}/bancas-por-seccion/${seccionId}/${camara}`); | ||||||
|     return data; |   return data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -140,13 +178,13 @@ export const getMesasPorEstablecimiento = async (establecimientoId: string): Pro | |||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getComposicionCongreso = async (): Promise<ComposicionData> => { | export const getComposicionCongreso = async (eleccionId: number): Promise<ComposicionData> => { | ||||||
|   const response = await apiClient.get('/resultados/composicion-congreso'); |   const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-congreso`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getBancadasDetalle = async (): Promise<BancadaDetalle[]> => { | export const getBancadasDetalle = async (eleccionId: number): Promise<BancadaDetalle[]> => { | ||||||
|   const response = await apiClient.get('/resultados/bancadas-detalle'); |   const response = await apiClient.get(`/elecciones/${eleccionId}/bancadas-detalle`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -155,24 +193,18 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> = | |||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getResultadosPorSeccion = async (seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => { | export const getResultadosPorSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => { | ||||||
|   const response = await apiClient.get(`/resultados/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`); |   const response = await apiClient.get(`/elecciones/${eleccionId}/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getDetalleSeccion = async (seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => { | export const getDetalleSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => { | ||||||
|   const response = await apiClient.get(`/resultados/seccion/${seccionId}?categoriaId=${categoriaId}`); |   const response = await apiClient.get(`/elecciones/${eleccionId}/seccion/${seccionId}?categoriaId=${categoriaId}`); | ||||||
|   return response.data; |   return response.data; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getResultadosPorMunicipioYCategoria = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => { | export const getResultadosPorMunicipio = async (eleccionId: number, municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => { | ||||||
|   const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`); |   const response = await apiClient.get(`/elecciones/${eleccionId}/partido/${municipioId}?categoriaId=${categoriaId}`); | ||||||
|   return response.data.resultados; |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| export const getResultadosPorMunicipio = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => { |  | ||||||
|   const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`); |  | ||||||
|   // La respuesta es un objeto, nosotros extraemos el array de resultados |  | ||||||
|   return response.data.resultados; |   return response.data.resultados; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| @@ -213,4 +245,117 @@ export const getRankingMunicipiosPorSeccion = async (seccionId: string): Promise | |||||||
| export const getEstablecimientosPorMunicipio = async (municipioId: string): Promise<CatalogoItem[]> => { | export const getEstablecimientosPorMunicipio = async (municipioId: string): Promise<CatalogoItem[]> => { | ||||||
|   const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`); |   const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`); | ||||||
|   return response.data; |   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,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,23 +1,23 @@ | |||||||
| // src/components/DevApp.tsx
 | // src/components/common/DevApp.tsx
 | ||||||
| import { BancasWidget } from './BancasWidget' | import { BancasWidget } from '../../features/legislativas/provinciales/BancasWidget' | ||||||
| import { CongresoWidget } from './CongresoWidget' | import { CongresoWidget } from '../../features/legislativas/provinciales/CongresoWidget' | ||||||
| import MapaBsAs from './MapaBsAs' | import MapaBsAs from '../../features/legislativas/provinciales/MapaBsAs' | ||||||
| import { DipSenTickerWidget } from './DipSenTickerWidget' | import { DipSenTickerWidget } from '../../features/legislativas/provinciales/DipSenTickerWidget' | ||||||
| import { TelegramaWidget } from './TelegramaWidget' | import { TelegramaWidget } from '../../features/legislativas/provinciales/TelegramaWidget' | ||||||
| import { ConcejalesWidget } from './ConcejalesWidget' | import { ConcejalesWidget } from '../../features/legislativas/provinciales/ConcejalesWidget' | ||||||
| import MapaBsAsSecciones from './MapaBsAsSecciones' | import MapaBsAsSecciones from '../../features/legislativas/provinciales/MapaBsAsSecciones' | ||||||
| import { SenadoresWidget } from './SenadoresWidget' | import { SenadoresWidget } from '../../features/legislativas/provinciales/SenadoresWidget' | ||||||
| import { DiputadosWidget } from './DiputadosWidget' | import { DiputadosWidget } from '../../features/legislativas/provinciales/DiputadosWidget' | ||||||
| import { ResumenGeneralWidget } from './ResumenGeneralWidget' | import { ResumenGeneralWidget } from '../../features/legislativas/provinciales/ResumenGeneralWidget' | ||||||
| import { SenadoresTickerWidget } from './SenadoresTickerWidget' | import { SenadoresTickerWidget } from '../../features/legislativas/provinciales/SenadoresTickerWidget' | ||||||
| import { DiputadosTickerWidget } from './DiputadosTickerWidget' | import { DiputadosTickerWidget } from '../../features/legislativas/provinciales/DiputadosTickerWidget' | ||||||
| import { ConcejalesTickerWidget } from './ConcejalesTickerWidget' | import { ConcejalesTickerWidget } from '../../features/legislativas/provinciales/ConcejalesTickerWidget' | ||||||
| import { DiputadosPorSeccionWidget } from './DiputadosPorSeccionWidget' | import { DiputadosPorSeccionWidget } from '../../features/legislativas/provinciales/DiputadosPorSeccionWidget' | ||||||
| import { SenadoresPorSeccionWidget } from './SenadoresPorSeccionWidget' | import { SenadoresPorSeccionWidget } from '../../features/legislativas/provinciales/SenadoresPorSeccionWidget' | ||||||
| import { ConcejalesPorSeccionWidget } from './ConcejalesPorSeccionWidget' | import { ConcejalesPorSeccionWidget } from '../../features/legislativas/provinciales/ConcejalesPorSeccionWidget' | ||||||
| import { ResultadosTablaDetalladaWidget } from './ResultadosTablaDetalladaWidget' | import { ResultadosTablaDetalladaWidget } from '../../features/legislativas/provinciales/ResultadosTablaDetalladaWidget' | ||||||
| import { ResultadosRankingMunicipioWidget } from './ResultadosRankingMunicipioWidget' | import { ResultadosRankingMunicipioWidget } from '../../features/legislativas/provinciales/ResultadosRankingMunicipioWidget' | ||||||
| import '../App.css'; | import '../../App.css'; | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| export const DevApp = () => { | export const DevApp = () => { | ||||||
| @@ -38,7 +38,7 @@ export const DevApp = () => { | |||||||
|         <DiputadosPorSeccionWidget /> |         <DiputadosPorSeccionWidget /> | ||||||
|         <SenadoresPorSeccionWidget /> |         <SenadoresPorSeccionWidget /> | ||||||
|         <ConcejalesPorSeccionWidget /> |         <ConcejalesPorSeccionWidget /> | ||||||
|         <CongresoWidget /> |         <CongresoWidget eleccionId={1} /> | ||||||
|         <BancasWidget /> |         <BancasWidget /> | ||||||
|         <MapaBsAs /> |         <MapaBsAs /> | ||||||
|         <MapaBsAsSecciones /> |         <MapaBsAsSecciones /> | ||||||
| @@ -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'; | import { useState, useEffect } from 'react'; | ||||||
| 
 | 
 | ||||||
| interface Props extends React.ImgHTMLAttributes<HTMLImageElement> { | interface Props extends React.ImgHTMLAttributes<HTMLImageElement> { | ||||||
| @@ -1,6 +1,6 @@ | |||||||
| // src/components/ParliamentLayout.tsx
 | // src/components/common/ParliamentLayout.tsx
 | ||||||
| import React, { useLayoutEffect } from 'react'; | import React, { useLayoutEffect } from 'react'; | ||||||
| import { assetBaseUrl } from '../apiService'; | import { assetBaseUrl } from '../../apiService'; | ||||||
| import { handleImageFallback } from './imageFallback'; | import { handleImageFallback } from './imageFallback'; | ||||||
| 
 | 
 | ||||||
| // Interfaces (no cambian)
 | // Interfaces (no cambian)
 | ||||||
| @@ -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,7 +1,7 @@ | |||||||
| // src/components/SenateLayout.tsx
 | // src/components/common/SenateLayout.tsx
 | ||||||
| import React, { useLayoutEffect } from 'react'; | import React, { useLayoutEffect } from 'react'; | ||||||
| import { handleImageFallback } from './imageFallback'; | import { handleImageFallback } from './imageFallback'; | ||||||
| import { assetBaseUrl } from '../apiService'; | import { assetBaseUrl } from '../../apiService'; | ||||||
| 
 | 
 | ||||||
| // Interfaces
 | // Interfaces
 | ||||||
| interface SeatFillData { | interface SeatFillData { | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| // src/components/imageFallback.ts
 | // src/components/common/imageFallback.ts
 | ||||||
| 
 | 
 | ||||||
| export function handleImageFallback(selector: string, fallbackImageUrl: string) { | export function handleImageFallback(selector: string, fallbackImageUrl: string) { | ||||||
|   // Le decimos a TypeScript que el resultado será una lista de elementos de imagen HTML
 |   // 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; | ||||||
|  | } | ||||||
| @@ -0,0 +1,163 @@ | |||||||
|  | // src/features/legislativas/nacionales/CongresoNacionalWidget.tsx | ||||||
|  | import { useState, Suspense, useMemo } from 'react'; | ||||||
|  | import { useSuspenseQuery } from '@tanstack/react-query'; | ||||||
|  | import { Tooltip } from 'react-tooltip'; | ||||||
|  | import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout'; | ||||||
|  | import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout'; | ||||||
|  | import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService'; | ||||||
|  | import styles from './CongresoNacionalWidget.module.css'; | ||||||
|  |  | ||||||
|  | interface CongresoNacionalWidgetProps { | ||||||
|  |   eleccionId: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const formatTimestamp = (dateString: string) => { | ||||||
|  |   if (!dateString) return '...'; | ||||||
|  |   const date = new Date(dateString); | ||||||
|  |   const day = String(date.getDate()).padStart(2, '0'); | ||||||
|  |   const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||||
|  |   const year = date.getFullYear(); | ||||||
|  |   const hours = String(date.getHours()).padStart(2, '0'); | ||||||
|  |   const minutes = String(date.getMinutes()).padStart(2, '0'); | ||||||
|  |   return `${day}/${month}/${year} ${hours}:${minutes}`; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => { | ||||||
|  |   const [camaraActiva, setCamaraActiva] = useState<'diputados' | 'senadores'>('diputados'); | ||||||
|  |   const [isHovering, setIsHovering] = useState(false); | ||||||
|  |  | ||||||
|  |   const { data } = useSuspenseQuery<ComposicionNacionalData>({ | ||||||
|  |     queryKey: ['composicionNacional', eleccionId], | ||||||
|  |     queryFn: () => getComposicionNacional(eleccionId), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const datosCamaraActual = data[camaraActiva]; | ||||||
|  |  | ||||||
|  |   const partidosOrdenados = useMemo(() => { | ||||||
|  |     if (!datosCamaraActual?.partidos) return []; | ||||||
|  |     const partidosACopiar = [...datosCamaraActual.partidos]; | ||||||
|  |     partidosACopiar.sort((a, b) => { | ||||||
|  |       const ordenA = camaraActiva === 'diputados' ? a.ordenDiputadosNacionales : a.ordenSenadoresNacionales; | ||||||
|  |       const ordenB = camaraActiva === 'diputados' ? b.ordenDiputadosNacionales : b.ordenSenadoresNacionales; | ||||||
|  |       return (ordenA ?? 999) - (ordenB ?? 999); | ||||||
|  |     }); | ||||||
|  |     return partidosACopiar; | ||||||
|  |   }, [datosCamaraActual, camaraActiva]); | ||||||
|  |  | ||||||
|  |   const partyDataParaLayout = useMemo(() => { | ||||||
|  |     if (camaraActiva === 'senadores') return partidosOrdenados; | ||||||
|  |     if (!partidosOrdenados || !datosCamaraActual.presidenteBancada?.color) return partidosOrdenados; | ||||||
|  |     const partidoPresidente = partidosOrdenados.find(p => p.color === datosCamaraActual.presidenteBancada!.color); | ||||||
|  |     if (!partidoPresidente) return partidosOrdenados; | ||||||
|  |  | ||||||
|  |     const adjustedPartyData = JSON.parse(JSON.stringify(partidosOrdenados)); | ||||||
|  |     const partidoAjustar = adjustedPartyData.find((p: PartidoComposicionNacional) => p.id === partidoPresidente.id); | ||||||
|  |  | ||||||
|  |     if (partidoAjustar) { | ||||||
|  |       const tipoBanca = datosCamaraActual.presidenteBancada.tipoBanca; | ||||||
|  |       if (tipoBanca === 'ganada' && partidoAjustar.bancasGanadas > 0) { | ||||||
|  |         partidoAjustar.bancasGanadas -= 1; | ||||||
|  |       } else if (tipoBanca === 'previa' && partidoAjustar.bancasFijos > 0) { | ||||||
|  |         partidoAjustar.bancasFijos -= 1; | ||||||
|  |       } else { | ||||||
|  |         if (partidoAjustar.bancasGanadas > 0) { | ||||||
|  |           partidoAjustar.bancasGanadas -= 1; | ||||||
|  |         } else if (partidoAjustar.bancasFijos > 0) { | ||||||
|  |           partidoAjustar.bancasFijos -= 1; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return adjustedPartyData; | ||||||
|  |   }, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]); | ||||||
|  |  | ||||||
|  |   // 2. Todas las props 'className' ahora usan el objeto 'styles' | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.congresoContainer}> | ||||||
|  |       <div className={styles.congresoGrafico}> | ||||||
|  |         <div | ||||||
|  |           className={`${styles.congresoHemicicloWrapper} ${isHovering ? styles.isHovering : ''}`} | ||||||
|  |           onMouseEnter={() => setIsHovering(true)} | ||||||
|  |           onMouseLeave={() => setIsHovering(false)} | ||||||
|  |         > | ||||||
|  |           {camaraActiva === 'diputados' ? | ||||||
|  |             <DiputadosNacionalesLayout | ||||||
|  |               partyData={partyDataParaLayout} | ||||||
|  |               presidenteBancada={datosCamaraActual.presidenteBancada || null} | ||||||
|  |               size={700} | ||||||
|  |             /> : | ||||||
|  |             <SenadoresNacionalesLayout | ||||||
|  |               partyData={partyDataParaLayout} | ||||||
|  |               presidenteBancada={datosCamaraActual.presidenteBancada || null} | ||||||
|  |               size={700} | ||||||
|  |             /> | ||||||
|  |           } | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.congresoFooter}> | ||||||
|  |           <div className={styles.footerLegend}> | ||||||
|  |             <div className={styles.footerLegendItem}> | ||||||
|  |               <span className={`${styles.legendIcon} ${styles.legendIconSolid}`}></span> | ||||||
|  |               <span>Bancas en juego</span> | ||||||
|  |             </div> | ||||||
|  |             <div className={styles.footerLegendItem}> | ||||||
|  |               <span className={`${styles.legendIcon} ${styles.legendIconRing}`}></span> | ||||||
|  |               <span>Bancas Fijas</span> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |           <div className={styles.footerTimestamp}> | ||||||
|  |             Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.congresoSummary}> | ||||||
|  |         <div className={styles.summaryHeader}> | ||||||
|  |           <h3>{datosCamaraActual.camaraNombre}</h3> | ||||||
|  |           <div className={styles.chamberTabs}> | ||||||
|  |             <button className={camaraActiva === 'diputados' ? styles.active : ''} onClick={() => setCamaraActiva('diputados')}> | ||||||
|  |               Diputados | ||||||
|  |             </button> | ||||||
|  |             <button className={camaraActiva === 'senadores' ? styles.active : ''} onClick={() => setCamaraActiva('senadores')}> | ||||||
|  |               Senadores | ||||||
|  |             </button> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.summaryMetric}> | ||||||
|  |           <span>Total de Bancas</span> | ||||||
|  |           <strong>{datosCamaraActual.totalBancas}</strong> | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.summaryMetric}> | ||||||
|  |           <span>Bancas en Juego</span> | ||||||
|  |           <strong>{datosCamaraActual.bancasEnJuego}</strong> | ||||||
|  |         </div> | ||||||
|  |         <hr /> | ||||||
|  |         <div className={styles.partidoListaContainer}> | ||||||
|  |           <ul className={styles.partidoLista}> | ||||||
|  |             {partidosOrdenados | ||||||
|  |               .filter(p => p.bancasTotales > 0) | ||||||
|  |               .map((partido: PartidoComposicionNacional) => ( | ||||||
|  |                 <li key={partido.id}> | ||||||
|  |                   <span className={styles.partidoColorBox} style={{ 'marginRight': '0.25rem', backgroundColor: partido.color || '#808080' }}></span> | ||||||
|  |                   <span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span> | ||||||
|  |                   <strong | ||||||
|  |                     className={styles.partidoBancas} | ||||||
|  |                     title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`} | ||||||
|  |                   > | ||||||
|  |                     {partido.bancasTotales} | ||||||
|  |                   </strong> | ||||||
|  |                 </li> | ||||||
|  |               ))} | ||||||
|  |           </ul> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <Tooltip id="party-tooltip" className={styles.partyTooltipContainer} /> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => { | ||||||
|  |   return ( | ||||||
|  |     <Suspense fallback={<div className={`${styles.congresoContainer} ${styles.loading}`} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}> | ||||||
|  |       <WidgetContent eleccionId={eleccionId} /> | ||||||
|  |     </Suspense> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,148 @@ | |||||||
|  | // src/features/legislativas/nacionales/HomeCarouselNacionalWidget.tsx | ||||||
|  |  | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getHomeResumenNacional } from '../../../apiService'; | ||||||
|  | import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||||
|  | import { assetBaseUrl } from '../../../apiService'; | ||||||
|  | import { Swiper, SwiperSlide } from 'swiper/react'; | ||||||
|  | import { Navigation, A11y } from 'swiper/modules'; | ||||||
|  | import { TfiMapAlt } from "react-icons/tfi"; | ||||||
|  |  | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css'; | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css/navigation'; | ||||||
|  | import styles from './HomeCarouselWidget.module.css'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |   eleccionId: number; | ||||||
|  |   categoriaId: number; | ||||||
|  |   titulo: string; | ||||||
|  |   mapLinkUrl: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||||
|  | const formatNumber = (num: number) => num.toLocaleString('es-AR'); | ||||||
|  | const formatDateTime = (dateString: string | undefined | null) => { | ||||||
|  |   if (!dateString) return '...'; | ||||||
|  |   try { | ||||||
|  |     const date = new Date(dateString); | ||||||
|  |     if (isNaN(date.getTime())) { | ||||||
|  |       return dateString; | ||||||
|  |     } | ||||||
|  |     const day = String(date.getDate()).padStart(2, '0'); | ||||||
|  |     const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||||
|  |     const year = date.getFullYear(); | ||||||
|  |     const hours = String(date.getHours()).padStart(2, '0'); | ||||||
|  |     const minutes = String(date.getMinutes()).padStart(2, '0'); | ||||||
|  |     return `${day}/${month}/${year}, ${hours}:${minutes} hs.`; | ||||||
|  |   } catch (e) { | ||||||
|  |     return dateString; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const HomeCarouselNacionalWidget = ({ eleccionId, categoriaId, titulo, mapLinkUrl }: Props) => { | ||||||
|  |   const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`; | ||||||
|  |   const prevButtonClass = `prev-${uniqueId}`; | ||||||
|  |   const nextButtonClass = `next-${uniqueId}`; | ||||||
|  |  | ||||||
|  |   const { data, isLoading, error } = useQuery({ | ||||||
|  |     queryKey: ['homeResumenNacional', eleccionId, categoriaId], | ||||||
|  |     queryFn: () => getHomeResumenNacional(eleccionId, categoriaId), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   if (isLoading) return <div>Cargando widget...</div>; | ||||||
|  |   if (error || !data) return <div>No se pudieron cargar los datos.</div>; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.homeCarouselWidget}> | ||||||
|  |       <div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}> | ||||||
|  |         <h2 className={styles.widgetTitle}>{titulo}</h2> | ||||||
|  |         <a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}> | ||||||
|  |           <TfiMapAlt /> | ||||||
|  |           <span className={styles.buttonText}>Ver Mapa</span> | ||||||
|  |         </a> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className={styles.carouselContainer}> | ||||||
|  |         <Swiper | ||||||
|  |           modules={[Navigation, A11y]} | ||||||
|  |           spaceBetween={16} | ||||||
|  |           slidesPerView={1.3} | ||||||
|  |           navigation={{ | ||||||
|  |             prevEl: `.${prevButtonClass}`, | ||||||
|  |             nextEl: `.${nextButtonClass}`, | ||||||
|  |           }} | ||||||
|  |           breakpoints={{ | ||||||
|  |             320: { slidesPerView: 1.25, spaceBetween: 10 }, | ||||||
|  |             430: { slidesPerView: 1.4, spaceBetween: 12 }, | ||||||
|  |             640: { slidesPerView: 2.5 }, | ||||||
|  |             1024: { slidesPerView: 3 }, | ||||||
|  |             1200: { slidesPerView: 3.5 } | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {data.resultados.map(candidato => ( | ||||||
|  |             <SwiperSlide key={candidato.agrupacionId}> | ||||||
|  |               <div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}> | ||||||
|  |                 <div className={styles.candidatePhotoWrapper}> | ||||||
|  |                   <ImageWithFallback | ||||||
|  |                     src={candidato.fotoUrl ?? undefined} | ||||||
|  |                     fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||||
|  |                     alt={candidato.nombreCandidato ?? ''} | ||||||
|  |                     className={styles.candidatePhoto} | ||||||
|  |                   /> | ||||||
|  |                 </div> | ||||||
|  |                 <div className={styles.candidateDetails}> | ||||||
|  |                   <div className={styles.candidateInfo}> | ||||||
|  |                     {candidato.nombreCandidato ? ( | ||||||
|  |                       <> | ||||||
|  |                         <span className={styles.candidateName}>{candidato.nombreCandidato}</span> | ||||||
|  |                         <span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                       </> | ||||||
|  |                     ) : ( | ||||||
|  |                       <span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                     )} | ||||||
|  |                   </div> | ||||||
|  |                   <div className={styles.candidateResults}> | ||||||
|  |                     <span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span> | ||||||
|  |                     <span className={styles.votes}>{formatNumber(candidato.votos)} votos</span> | ||||||
|  |                   </div> | ||||||
|  |                 </div> | ||||||
|  |               </div> | ||||||
|  |             </SwiperSlide> | ||||||
|  |           ))} | ||||||
|  |         </Swiper> | ||||||
|  |  | ||||||
|  |         <div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div> | ||||||
|  |         <div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className={styles.topStatsBar}> | ||||||
|  |         <div> | ||||||
|  |           <span>Participación</span> | ||||||
|  |           <strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong> | ||||||
|  |         </div> | ||||||
|  |         <div> | ||||||
|  |           <span className={styles.longText}>Mesas escrutadas</span> | ||||||
|  |           <span className={styles.shortText}>Escrutado</span> | ||||||
|  |           <strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong> | ||||||
|  |         </div> | ||||||
|  |         <div> | ||||||
|  |           <span className={styles.longText}>Votos en blanco</span> | ||||||
|  |           <span className={styles.shortText}>En blanco</span> | ||||||
|  |           <strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong> | ||||||
|  |         </div> | ||||||
|  |         <div> | ||||||
|  |           <span className={styles.longText}>Votos totales</span> | ||||||
|  |           <span className={styles.shortText}>Votos</span> | ||||||
|  |           <strong>{formatNumber(data.votosTotales)}</strong> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div className={styles.widgetFooter}> | ||||||
|  |         Última actualización: {formatDateTime(data.ultimaActualizacion)} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,201 @@ | |||||||
|  | // src/features/legislativas/nacionales/HomeCarouselProvincialWidget.tsx | ||||||
|  |  | ||||||
|  | import { useState, useMemo, useEffect } from 'react'; | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import Select, { type SingleValue, type StylesConfig } from 'react-select'; | ||||||
|  | import { getHomeResumen, getProvincias } from '../../../apiService'; | ||||||
|  | import type { CatalogoItem } from '../../../types/types'; | ||||||
|  | import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||||
|  | import { assetBaseUrl } from '../../../apiService'; | ||||||
|  | import { Swiper, SwiperSlide } from 'swiper/react'; | ||||||
|  | import { Navigation, A11y } from 'swiper/modules'; | ||||||
|  |  | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css'; | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css/navigation'; | ||||||
|  | import styles from './HomeCarouselWidget.module.css'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |   eleccionId: number; | ||||||
|  |   categoriaId: number | string; | ||||||
|  |   titulo: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface OptionType { | ||||||
|  |   value: string; | ||||||
|  |   label: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const PROVINCIAS_QUE_RENUEVAN_SENADORES = new Set(['01', '06', '08', '15', '16', '17', '22', '24']); | ||||||
|  | const CATEGORIA_SENADORES = 2; | ||||||
|  |  | ||||||
|  | const customSelectStyles: StylesConfig<OptionType, false> = { | ||||||
|  |   menuList: (provided) => ({ | ||||||
|  |     ...provided, | ||||||
|  |     maxHeight: '180px', | ||||||
|  |   }), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||||
|  | const formatNumber = (num: number) => num.toLocaleString('es-AR'); | ||||||
|  | const formatDateTime = (dateString: string | undefined | null) => { | ||||||
|  |   if (!dateString) return '...'; | ||||||
|  |   try { | ||||||
|  |     const date = new Date(dateString); | ||||||
|  |     const day = String(date.getDate()).padStart(2, '0'); | ||||||
|  |     const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||||
|  |     const year = date.getFullYear(); | ||||||
|  |     const hours = String(date.getHours()).padStart(2, '0'); | ||||||
|  |     const minutes = String(date.getMinutes()).padStart(2, '0'); | ||||||
|  |     return `${day}/${month}/${year}, ${hours}:${minutes} hs.`; | ||||||
|  |   } catch (e) { | ||||||
|  |     return dateString; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const HomeCarouselProvincialWidget = ({ eleccionId, categoriaId, titulo }: Props) => { | ||||||
|  |   const [selectedProvince, setSelectedProvince] = useState<OptionType | null>({ value: '01', label: 'CABA' }); | ||||||
|  |  | ||||||
|  |   const { data: provincias = [], isLoading: isLoadingProvincias } = useQuery<CatalogoItem[]>({ | ||||||
|  |     queryKey: ['provincias'], | ||||||
|  |     queryFn: getProvincias, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const provinceOptions: OptionType[] = useMemo(() => { | ||||||
|  |     const allOptions = provincias.map(p => ({ value: p.id, label: p.nombre })); | ||||||
|  |     if (Number(categoriaId) === CATEGORIA_SENADORES) { | ||||||
|  |       return allOptions.filter(opt => PROVINCIAS_QUE_RENUEVAN_SENADORES.has(opt.value)); | ||||||
|  |     } | ||||||
|  |     return allOptions; | ||||||
|  |   }, [provincias, categoriaId]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (provinceOptions.length > 0) { | ||||||
|  |       if (!selectedProvince) { | ||||||
|  |         const defaultOption = provinceOptions.find(opt => opt.value === '01'); | ||||||
|  |         setSelectedProvince(defaultOption || provinceOptions[0]); | ||||||
|  |       } else { | ||||||
|  |         const isSelectedStillValid = provinceOptions.some(opt => opt.value === selectedProvince.value); | ||||||
|  |         if (!isSelectedStillValid) { | ||||||
|  |           setSelectedProvince(provinceOptions[0]); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, [provinceOptions, selectedProvince]); | ||||||
|  |  | ||||||
|  |   const { data, isLoading, error } = useQuery({ | ||||||
|  |     queryKey: ['homeResumen', eleccionId, selectedProvince?.value, categoriaId], | ||||||
|  |     queryFn: () => getHomeResumen(eleccionId, selectedProvince!.value, Number(categoriaId)), | ||||||
|  |     enabled: !!selectedProvince, | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`; | ||||||
|  |   const prevButtonClass = `prev-${uniqueId}`; | ||||||
|  |   const nextButtonClass = `next-${uniqueId}`; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.homeCarouselWidget}> | ||||||
|  |       <div className={styles.widgetHeader}> | ||||||
|  |         <h2 className={styles.widgetTitle}>{`${titulo} - ${selectedProvince?.label || '...'}`}</h2> | ||||||
|  |         <div className={styles.provinceSelector}> | ||||||
|  |           <Select | ||||||
|  |             value={selectedProvince} | ||||||
|  |             options={provinceOptions} | ||||||
|  |             onChange={(option: SingleValue<OptionType>) => option && setSelectedProvince(option)} | ||||||
|  |             isLoading={isLoadingProvincias} | ||||||
|  |             isSearchable={true} | ||||||
|  |             placeholder="Seleccionar provincia..." | ||||||
|  |             styles={customSelectStyles} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {(isLoading || !selectedProvince) && <div>Cargando resultados...</div>} | ||||||
|  |       {error && <div>No se pudieron cargar los datos.</div>} | ||||||
|  |       {data && selectedProvince && ( | ||||||
|  |         <> | ||||||
|  |           <div className={styles.carouselContainer}> | ||||||
|  |             <Swiper | ||||||
|  |               modules={[Navigation, A11y]} | ||||||
|  |               spaceBetween={16} | ||||||
|  |               slidesPerView={1.3} | ||||||
|  |               navigation={{ | ||||||
|  |                 prevEl: `.${prevButtonClass}`, | ||||||
|  |                 nextEl: `.${nextButtonClass}`, | ||||||
|  |               }} | ||||||
|  |               breakpoints={{ | ||||||
|  |                 320: { slidesPerView: 1.25, spaceBetween: 10 }, | ||||||
|  |                 430: { slidesPerView: 1.4, spaceBetween: 12 }, | ||||||
|  |                 640: { slidesPerView: 2.5 }, | ||||||
|  |                 1024: { slidesPerView: 3 }, | ||||||
|  |                 1200: { slidesPerView: 3.5 } | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               {data.resultados.map(candidato => ( | ||||||
|  |                 <SwiperSlide key={candidato.agrupacionId}> | ||||||
|  |                   <div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}> | ||||||
|  |                     <div className={styles.candidatePhotoWrapper}> | ||||||
|  |                       <ImageWithFallback | ||||||
|  |                         src={candidato.fotoUrl ?? undefined} | ||||||
|  |                         fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||||
|  |                         alt={candidato.nombreCandidato ?? ''} | ||||||
|  |                         className={styles.candidatePhoto} | ||||||
|  |                       /> | ||||||
|  |                     </div> | ||||||
|  |                     <div className={styles.candidateDetails}> | ||||||
|  |                       <div className={styles.candidateInfo}> | ||||||
|  |                         {candidato.nombreCandidato ? ( | ||||||
|  |                           <> | ||||||
|  |                             <span className={styles.candidateName}>{candidato.nombreCandidato}</span> | ||||||
|  |                             <span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                           </> | ||||||
|  |                         ) : ( | ||||||
|  |                           <span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                         )} | ||||||
|  |                       </div> | ||||||
|  |                       <div className={styles.candidateResults}> | ||||||
|  |                         <span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span> | ||||||
|  |                         <span className={styles.votes}>{formatNumber(candidato.votos)} votos</span> | ||||||
|  |                       </div> | ||||||
|  |                     </div> | ||||||
|  |                   </div> | ||||||
|  |                 </SwiperSlide> | ||||||
|  |               ))} | ||||||
|  |             </Swiper> | ||||||
|  |  | ||||||
|  |             <div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div> | ||||||
|  |             <div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div className={styles.topStatsBar}> | ||||||
|  |             <div> | ||||||
|  |               <span>Participación</span> | ||||||
|  |               <strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <span className={styles.longText}>Mesas escrutadas</span> | ||||||
|  |               <span className={styles.shortText}>Escrutado</span> | ||||||
|  |               <strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <span className={styles.longText}>Votos en blanco</span> | ||||||
|  |               <span className={styles.shortText}>En blanco</span> | ||||||
|  |               <strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <span className={styles.longText}>Votos totales</span> | ||||||
|  |               <span className={styles.shortText}>Votos</span> | ||||||
|  |               <strong>{formatNumber(data.votosTotales)}</strong> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |  | ||||||
|  |           <div className={styles.widgetFooter}> | ||||||
|  |             Última actualización: {formatDateTime(data.ultimaActualizacion)} | ||||||
|  |           </div> | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,478 @@ | |||||||
|  | /* src/features/legislativas/nacionales/HomeCarouselWidget.module.css */ | ||||||
|  |  | ||||||
|  | .homeCarouselWidget { | ||||||
|  |     --primary-text: #212529; | ||||||
|  |     --secondary-text: #6c757d; | ||||||
|  |     --border-color: #dee2e6; | ||||||
|  |     --background-light: #f8f9fa; | ||||||
|  |     --background-white: #ffffff; | ||||||
|  |     --shadow: 0 2px 8px rgba(0, 0, 0, 0.07); | ||||||
|  |     --font-family-sans: "Roboto", system-ui, sans-serif; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget, | ||||||
|  | .homeCarouselWidget * { | ||||||
|  |     font-family: var(--font-family-sans) !important; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget { | ||||||
|  |     background-color: var(--background-white); | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 8px; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     max-width: 1200px; | ||||||
|  |     margin: 2rem auto; | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .carouselContainer { | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .widgetHeader { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 1rem; | ||||||
|  |     margin: 0 0 0.5rem 0; | ||||||
|  |     padding-bottom: 0.5rem; | ||||||
|  |     border-bottom: 1px solid var(--border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .widgetTitle { | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: 900; | ||||||
|  |     color: var(--primary-text); | ||||||
|  |     margin: 0; | ||||||
|  |     padding: 0; | ||||||
|  |     border: none; | ||||||
|  |     text-align: left; | ||||||
|  |     flex-grow: 1; | ||||||
|  |     white-space: nowrap; | ||||||
|  |     overflow: hidden; | ||||||
|  |     text-overflow: ellipsis; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .provinceSelector { | ||||||
|  |     min-width: 180px; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     z-index: 9999; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapLinkButton { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 0.4rem; | ||||||
|  |     background-color: #007bff; | ||||||
|  |     color: white; | ||||||
|  |     padding: 0.5rem 1rem; | ||||||
|  |     border-radius: 9999px; | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     font-weight: 600; | ||||||
|  |     text-decoration: none; | ||||||
|  |     white-space: nowrap; | ||||||
|  |     transition: background-color 0.2s ease, box-shadow 0.2s ease; | ||||||
|  |     box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | ||||||
|  |     flex-shrink: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapLinkButton svg { | ||||||
|  |     font-size: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .buttonText { | ||||||
|  |     display: inline; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .mapLinkButton:hover { | ||||||
|  |     background-color: #0056b3; | ||||||
|  |     color: white; | ||||||
|  |     text-decoration: none; | ||||||
|  |     box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .topStatsBar { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-around; | ||||||
|  |     background-color: transparent; | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 8px; | ||||||
|  |     padding: 0.3rem 0.5rem; | ||||||
|  |     margin-bottom: 0.5rem; | ||||||
|  |     margin-top: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .topStatsBar>div { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: baseline; | ||||||
|  |     gap: 0.5rem; | ||||||
|  |     border-right: 1px solid var(--border-color); | ||||||
|  |     padding: 0 0.5rem; | ||||||
|  |     flex-grow: 1; | ||||||
|  |     justify-content: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .topStatsBar>div:last-child { | ||||||
|  |     border-right: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .topStatsBar span { | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     color: var(--secondary-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .topStatsBar strong { | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |     font-weight: 600; | ||||||
|  |     color: var(--primary-text); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateCard { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 0.75rem; | ||||||
|  |     background: var(--background-white); | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 12px; | ||||||
|  |     padding: 0.75rem; | ||||||
|  |     box-shadow: var(--shadow); | ||||||
|  |     border-left: 5px solid; | ||||||
|  |     border-left-color: var(--candidate-color, #ccc); | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatePhotoWrapper { | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     width: 60px; | ||||||
|  |     height: 60px; | ||||||
|  |     border-radius: 8px; | ||||||
|  |     overflow: hidden; | ||||||
|  |     background-color: var(--candidate-color, #e9ecef); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatePhoto { | ||||||
|  |     width: 100% !important; | ||||||
|  |     height: 100% !important; | ||||||
|  |     object-fit: cover; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateDetails { | ||||||
|  |     flex-grow: 1; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: center; | ||||||
|  |     min-width: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateInfo { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     justify-content: center; | ||||||
|  |     align-items: flex-start; | ||||||
|  |     gap: 0.1rem; | ||||||
|  |     min-width: 0; | ||||||
|  |     margin-right: 0.75rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateName, | ||||||
|  | .partyName { | ||||||
|  |     white-space: nowrap; | ||||||
|  |     overflow: hidden; | ||||||
|  |     text-overflow: ellipsis; | ||||||
|  |     display: block; | ||||||
|  |     width: 100%; | ||||||
|  |     margin: 0; | ||||||
|  |     color: var(--primary-text); | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateName { | ||||||
|  |     font-size: 0.95rem; | ||||||
|  |     font-weight: 700; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partyName { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: var(--secondary-text); | ||||||
|  |     font-weight: 400; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidateResults { | ||||||
|  |     text-align: right; | ||||||
|  |     flex-shrink: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .percentage { | ||||||
|  |     display: block; | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |     color: var(--primary-text); | ||||||
|  |     line-height: 1.1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .votes { | ||||||
|  |     font-size: 0.75rem; | ||||||
|  |     color: var(--secondary-text); | ||||||
|  |     white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Estilo base para ambos botones */ | ||||||
|  | .navButton { | ||||||
|  |     width: 30px; | ||||||
|  |     height: 30px; | ||||||
|  |     background-color: rgba(255, 255, 255, 0.9); | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 50%; | ||||||
|  |     box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); | ||||||
|  |     transition: opacity 0.2s; | ||||||
|  |     position: absolute; | ||||||
|  |     top: 50%; | ||||||
|  |     transform: translateY(-50%); | ||||||
|  |     margin-top: 0; | ||||||
|  |     z-index: 10; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Usamos el pseudo-elemento ::after para mostrar el icono SVG como fondo */ | ||||||
|  | .navButton::after { | ||||||
|  |     content: ''; | ||||||
|  |     /* Es necesario para que el pseudo-elemento se muestre */ | ||||||
|  |     display: block; | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     background-repeat: no-repeat; | ||||||
|  |     background-position: center; | ||||||
|  |     /* Ajustamos el tamaño del icono dentro del botón */ | ||||||
|  |     background-size: 75%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Posición y contenido específico para cada botón */ | ||||||
|  | .navButtonPrev { | ||||||
|  |     left: -10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navButtonPrev::after { | ||||||
|  |     /* SVG de flecha izquierda (chevron) codificado en Base64 */ | ||||||
|  |     background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSIxNSA2IDkgMTIgMTUgMTgiPjwvcG9seWxpbmU+PC9zdmc+"); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navButtonNext { | ||||||
|  |     right: -10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navButtonNext::after { | ||||||
|  |     /* SVG de flecha derecha (chevron) codificado en Base64 */ | ||||||
|  |     background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSI5IDYgMTUgMTIgOSAxOCI+PC9wb2x5bGluZT48L3N2Zz4="); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Swiper añade esta clase al botón cuando está deshabilitado */ | ||||||
|  | .navButton.swiper-button-disabled { | ||||||
|  |     opacity: 0; | ||||||
|  |     pointer-events: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-slide) { | ||||||
|  |     background: transparent !important; | ||||||
|  |     color: initial !important; | ||||||
|  |     text-align: left !important; | ||||||
|  |     height: auto !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after { | ||||||
|  |     content: ''; | ||||||
|  |     position: absolute; | ||||||
|  |     right: -8px; | ||||||
|  |     top: 20%; | ||||||
|  |     bottom: 20%; | ||||||
|  |     width: 1px; | ||||||
|  |     background-color: var(--border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- INICIO DE LA MODIFICACIÓN DE FLECHAS --- */ | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-button-prev), | ||||||
|  | .homeCarouselWidget :global(.swiper-button-next) { | ||||||
|  |     width: 30px !important; | ||||||
|  |     height: 30px !important; | ||||||
|  |     background-color: rgba(255, 255, 255, 0.9) !important; | ||||||
|  |     border: 1px solid var(--border-color); | ||||||
|  |     border-radius: 50%; | ||||||
|  |     box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); | ||||||
|  |     transition: opacity 0.2s; | ||||||
|  |     position: absolute !important; | ||||||
|  |     top: 50% !important; | ||||||
|  |     transform: translateY(-50%) !important; | ||||||
|  |     margin-top: 0 !important; | ||||||
|  |     z-index: 10; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-button-prev:after), | ||||||
|  | .homeCarouselWidget :global(.swiper-button-next:after) { | ||||||
|  |     display: block !important; | ||||||
|  |     font-family: 'swiper-icons'; | ||||||
|  |     font-size: 14px !important; | ||||||
|  |     font-weight: bold !important; | ||||||
|  |     color: var(--primary-text) !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-button-prev) { | ||||||
|  |     left: 10px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-button-next) { | ||||||
|  |     right: 10px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-button-disabled) { | ||||||
|  |     opacity: 0 !important; | ||||||
|  |     pointer-events: none !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .widgetFooter { | ||||||
|  |     text-align: right; | ||||||
|  |     font-size: 0.75rem; | ||||||
|  |     color: var(--secondary-text); | ||||||
|  |     margin-top: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .shortText { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |     .homeCarouselWidget .widgetHeader { | ||||||
|  |         /* Comportamiento por defecto en móvil: apilado y centrado */ | ||||||
|  |         flex-direction: column; | ||||||
|  |         align-items: center; | ||||||
|  |         gap: 0.75rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /* NUEVA CLASE MODIFICADORA para los widgets con botón */ | ||||||
|  |     .homeCarouselWidget .headerSingleLine { | ||||||
|  |         flex-direction: row; | ||||||
|  |         justify-content: space-between; | ||||||
|  |         align-items: center; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .widgetTitle { | ||||||
|  |         text-align: center; | ||||||
|  |         font-size: 1.1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /* Ajuste para que el título vuelva a la izquierda en la vista de una línea */ | ||||||
|  |     .headerSingleLine .widgetTitle { | ||||||
|  |         text-align: left; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .provinceSelector { | ||||||
|  |         min-width: 100%; | ||||||
|  |         z-index: 9999; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .mapLinkButton { | ||||||
|  |         padding: 0.5rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .buttonText { | ||||||
|  |         display: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .topStatsBar { | ||||||
|  |         display: grid; | ||||||
|  |         grid-template-columns: repeat(2, 1fr); | ||||||
|  |         gap: 0.2rem; | ||||||
|  |         padding: 0.3rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .topStatsBar>div { | ||||||
|  |         padding: 0.25rem 0.5rem; | ||||||
|  |         border-right: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .topStatsBar>div:nth-child(odd) { | ||||||
|  |         border-right: 1px solid var(--border-color); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .longText { | ||||||
|  |         display: none; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .shortText { | ||||||
|  |         display: inline; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .topStatsBar span { | ||||||
|  |         font-size: 0.8rem; | ||||||
|  |         text-align: left; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .topStatsBar strong { | ||||||
|  |         font-size: 0.85rem; | ||||||
|  |         text-align: right; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /* Ajustamos los botones custom en mobile */ | ||||||
|  |     .navButton { | ||||||
|  |         width: 32px; | ||||||
|  |         height: 32px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .navButton::after { | ||||||
|  |         line-height: 32px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .navButtonPrev { | ||||||
|  |         left: -10px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .navButtonNext { | ||||||
|  |         right: -10px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .candidateCard { | ||||||
|  |         gap: 0.5rem; | ||||||
|  |         padding: 0.5rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .candidatePhotoWrapper { | ||||||
|  |         width: 50px; | ||||||
|  |         height: 50px; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .candidateName { | ||||||
|  |         font-size: 0.9rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .percentage { | ||||||
|  |         font-size: 1.1rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .votes { | ||||||
|  |         font-size: 0.7rem; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .homeCarouselWidget .widgetFooter { | ||||||
|  |         text-align: center; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Mantenemos estos estilos globales por si acaso */ | ||||||
|  | .homeCarouselWidget :global(.swiper-slide) { | ||||||
|  |     background: transparent !important; | ||||||
|  |     color: initial !important; | ||||||
|  |     text-align: left !important; | ||||||
|  |     height: auto !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after { | ||||||
|  |     content: ''; | ||||||
|  |     position: absolute; | ||||||
|  |     right: -8px; | ||||||
|  |     top: 20%; | ||||||
|  |     bottom: 20%; | ||||||
|  |     width: 1px; | ||||||
|  |     background-color: var(--border-color); | ||||||
|  | } | ||||||
| @@ -0,0 +1,149 @@ | |||||||
|  | // src/features/legislativas/nacionales/HomeCarouselWidget.tsx | ||||||
|  |  | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getHomeResumen } from '../../../apiService'; | ||||||
|  | import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||||
|  | import { assetBaseUrl } from '../../../apiService'; | ||||||
|  | import { Swiper, SwiperSlide } from 'swiper/react'; | ||||||
|  | import { Navigation, A11y } from 'swiper/modules'; | ||||||
|  | import { TfiMapAlt } from "react-icons/tfi"; | ||||||
|  |  | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css'; | ||||||
|  | // @ts-ignore | ||||||
|  | import 'swiper/css/navigation'; | ||||||
|  | import styles from './HomeCarouselWidget.module.css'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |     eleccionId: number; | ||||||
|  |     distritoId: string; | ||||||
|  |     categoriaId: number; | ||||||
|  |     titulo: string; | ||||||
|  |     mapLinkUrl: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||||
|  | const formatNumber = (num: number) => num.toLocaleString('es-AR'); | ||||||
|  | const formatDateTime = (dateString: string | undefined | null) => { | ||||||
|  |     if (!dateString) return '...'; | ||||||
|  |     try { | ||||||
|  |         const date = new Date(dateString); | ||||||
|  |         if (isNaN(date.getTime())) { | ||||||
|  |             return dateString; | ||||||
|  |         } | ||||||
|  |         const day = String(date.getDate()).padStart(2, '0'); | ||||||
|  |         const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||||
|  |         const year = date.getFullYear(); | ||||||
|  |         const hours = String(date.getHours()).padStart(2, '0'); | ||||||
|  |         const minutes = String(date.getMinutes()).padStart(2, '0'); | ||||||
|  |         return `${day}/${month}/${year}, ${hours}:${minutes} hs.`; | ||||||
|  |     } catch (e) { | ||||||
|  |         return dateString; | ||||||
|  |     } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo, mapLinkUrl }: Props) => { | ||||||
|  |     const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`; | ||||||
|  |     const prevButtonClass = `prev-${uniqueId}`; | ||||||
|  |     const nextButtonClass = `next-${uniqueId}`; | ||||||
|  |  | ||||||
|  |     const { data, isLoading, error } = useQuery({ | ||||||
|  |         queryKey: ['homeResumen', eleccionId, distritoId, categoriaId], | ||||||
|  |         queryFn: () => getHomeResumen(eleccionId, distritoId, categoriaId), | ||||||
|  |         refetchInterval: 180000, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (isLoading) return <div>Cargando widget...</div>; | ||||||
|  |     if (error || !data) return <div>No se pudieron cargar los datos.</div>; | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <div className={styles.homeCarouselWidget}> | ||||||
|  |             <div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}> | ||||||
|  |                 <h2 className={styles.widgetTitle}>{titulo}</h2> | ||||||
|  |                 <a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}> | ||||||
|  |                     <TfiMapAlt /> | ||||||
|  |                     <span className={styles.buttonText}>Ver Mapa</span> | ||||||
|  |                 </a> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className={styles.carouselContainer}> | ||||||
|  |                 <Swiper | ||||||
|  |                     modules={[Navigation, A11y]} | ||||||
|  |                     spaceBetween={16} | ||||||
|  |                     slidesPerView={1.3} | ||||||
|  |                     navigation={{ | ||||||
|  |                         prevEl: `.${prevButtonClass}`, | ||||||
|  |                         nextEl: `.${nextButtonClass}`, | ||||||
|  |                     }} | ||||||
|  |                     breakpoints={{ | ||||||
|  |                         320: { slidesPerView: 1.25, spaceBetween: 10 }, | ||||||
|  |                         430: { slidesPerView: 1.4, spaceBetween: 12 }, | ||||||
|  |                         640: { slidesPerView: 2.5 }, | ||||||
|  |                         1024: { slidesPerView: 3 }, | ||||||
|  |                         1200: { slidesPerView: 3.5 } | ||||||
|  |                     }} | ||||||
|  |                 > | ||||||
|  |                     {data.resultados.map(candidato => ( | ||||||
|  |                         <SwiperSlide key={candidato.agrupacionId}> | ||||||
|  |                             <div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}> | ||||||
|  |                                 <div className={styles.candidatePhotoWrapper}> | ||||||
|  |                                     <ImageWithFallback | ||||||
|  |                                         src={candidato.fotoUrl ?? undefined} | ||||||
|  |                                         fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||||
|  |                                         alt={candidato.nombreCandidato ?? ''} | ||||||
|  |                                         className={styles.candidatePhoto} | ||||||
|  |                                     /> | ||||||
|  |                                 </div> | ||||||
|  |                                 <div className={styles.candidateDetails}> | ||||||
|  |                                     <div className={styles.candidateInfo}> | ||||||
|  |                                         {candidato.nombreCandidato ? ( | ||||||
|  |                                             <> | ||||||
|  |                                                 <span className={styles.candidateName}>{candidato.nombreCandidato}</span> | ||||||
|  |                                                 <span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                                             </> | ||||||
|  |                                         ) : ( | ||||||
|  |                                             <span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span> | ||||||
|  |                                         )} | ||||||
|  |                                     </div> | ||||||
|  |                                     <div className={styles.candidateResults}> | ||||||
|  |                                         <span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span> | ||||||
|  |                                         <span className={styles.votes}>{formatNumber(candidato.votos)} votos</span> | ||||||
|  |                                     </div> | ||||||
|  |                                 </div> | ||||||
|  |                             </div> | ||||||
|  |                         </SwiperSlide> | ||||||
|  |                     ))} | ||||||
|  |                 </Swiper> | ||||||
|  |  | ||||||
|  |                 <div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div> | ||||||
|  |                 <div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className={styles.topStatsBar}> | ||||||
|  |                 <div> | ||||||
|  |                     <span>Participación</span> | ||||||
|  |                     <strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <span className={styles.longText}>Mesas escrutadas</span> | ||||||
|  |                     <span className={styles.shortText}>Escrutado</span> | ||||||
|  |                     <strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <span className={styles.longText}>Votos en blanco</span> | ||||||
|  |                     <span className={styles.shortText}>En blanco</span> | ||||||
|  |                     <strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong> | ||||||
|  |                 </div> | ||||||
|  |                 <div> | ||||||
|  |                     <span className={styles.longText}>Votos totales</span> | ||||||
|  |                     <span className={styles.shortText}>Votos</span> | ||||||
|  |                     <strong>{formatNumber(data.votosTotales)}</strong> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div className={styles.widgetFooter}> | ||||||
|  |                 Última actualización: {formatDateTime(data.ultimaActualizacion)} | ||||||
|  |             </div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,579 @@ | |||||||
|  | /* src/features/legislativas/nacionales/PanelNacional.module.css */ | ||||||
|  |  | ||||||
|  | /* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */ | ||||||
|  | .panelNacionalContainer, | ||||||
|  | .panelNacionalContainer * { | ||||||
|  |     font-family: 'Roboto', sans-serif !important; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelNacionalContainer { | ||||||
|  |   max-width: 900px; | ||||||
|  |   margin: auto; | ||||||
|  |   border: 1px solid #e0e0e0; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   position: relative; | ||||||
|  |   padding: 10px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelHeader { | ||||||
|  |   padding: 1rem 1.5rem; | ||||||
|  |   border-bottom: 1px solid #e0e0e0; | ||||||
|  |   position: relative; | ||||||
|  |   z-index: 20; | ||||||
|  |   background-color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .headerTopRow { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: flex-start; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- ESTILOS PARA REACT-SELECT USANDO MÓDULOS --- */ | ||||||
|  | .categoriaSelectorContainer { | ||||||
|  |   min-width: 220px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__control) { | ||||||
|  |   border-radius: 8px !important; | ||||||
|  |   border: 1px solid #e0e0e0 !important; | ||||||
|  |   box-shadow: none !important; | ||||||
|  |   transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__control--is-focused) { | ||||||
|  |   border-color: #007bff !important; | ||||||
|  |   box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__single-value) { | ||||||
|  |   font-weight: 500; | ||||||
|  |   color: #333; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__menu) { | ||||||
|  |   border-radius: 8px !important; | ||||||
|  |   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important; | ||||||
|  |   border: 1px solid #e0e0e0 !important; | ||||||
|  |   margin-top: 4px !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__option) { | ||||||
|  |   cursor: pointer; | ||||||
|  |   transition: background-color 0.2s, color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__option--is-focused) { | ||||||
|  |   background-color: #f0f8ff; | ||||||
|  |   color: #333; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__option--is-selected) { | ||||||
|  |   background-color: #007bff; | ||||||
|  |   color: white; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__indicator-separator) { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__indicator) { | ||||||
|  |   color: #a0a0a0; | ||||||
|  |   transition: color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelectorContainer :global(.categoriaSelector__indicator:hover) { | ||||||
|  |   color: #333; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */ | ||||||
|  | .breadcrumbsContainer { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 0.5rem; | ||||||
|  |   font-size: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbItem, | ||||||
|  | .breadcrumbItemActual { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   padding: 0.4rem 0.8rem; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   transition: background-color 0.2s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbItem { | ||||||
|  |   background-color: #f0f0f0; | ||||||
|  |   border: 1px solid #e0e0e0; | ||||||
|  |   color: #333; | ||||||
|  |   cursor: pointer; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbItem:hover { | ||||||
|  |   background-color: #e0e0e0; | ||||||
|  |   border-color: #d1d1d1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbItemActual { | ||||||
|  |   background-color: transparent; | ||||||
|  |   color: #000; | ||||||
|  |   font-weight: 700; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbIcon { | ||||||
|  |   margin-right: 0.4rem; | ||||||
|  |   font-size: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .breadcrumbSeparator { | ||||||
|  |   color: #a0a0a0; | ||||||
|  |   font-size: 1.2rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelMainContent { | ||||||
|  |   display: flex; | ||||||
|  |   height: 75vh; | ||||||
|  |   min-height: 500px; | ||||||
|  |   transition: all 0.5s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapaColumn { | ||||||
|  |   flex: 2; | ||||||
|  |   position: relative; | ||||||
|  |   transition: flex 0.5s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultadosColumn { | ||||||
|  |   flex: 1; | ||||||
|  |   overflow-y: auto; | ||||||
|  |   padding: 1rem; | ||||||
|  |   transition: all 0.5s ease-in-out; | ||||||
|  |   min-width: 350px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoFila { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 0.75rem; | ||||||
|  |   padding: 0.75rem 0; | ||||||
|  |   border-bottom: 1px solid #f0f0f0; | ||||||
|  |   border-left: 5px solid; | ||||||
|  |   border-radius: 12px; | ||||||
|  |   padding-left: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoLogo { | ||||||
|  |   flex-shrink: 0; | ||||||
|  |   width: 65px; | ||||||
|  |   height: 65px; | ||||||
|  |   border-radius: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoLogo img { | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   border-radius: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoMainContent { | ||||||
|  |   flex-grow: 1; | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: 1fr auto; | ||||||
|  |   grid-template-rows: auto auto; | ||||||
|  |   align-items: center; | ||||||
|  |   gap: 0.5rem 0.75rem; /* Aumentamos el gap vertical para más aire */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* El contenedor de la barra */ | ||||||
|  | .partidoBarraConVotos { | ||||||
|  |   grid-column: 1 / 3; | ||||||
|  |   position: relative; /* Clave para superponer el texto */ | ||||||
|  |   height: 28px; | ||||||
|  |   background-color: #f0f0f0; | ||||||
|  |   border-radius: 6px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* La barra de progreso coloreada */ | ||||||
|  | .partidoBarraForeground { | ||||||
|  |   height: 100%; | ||||||
|  |   border-radius: 6px; | ||||||
|  |   transition: width 0.5s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* La ÚNICA capa de texto, posicionada de forma absoluta */ | ||||||
|  | .partidoVotosEnBarra { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   right: 10px; | ||||||
|  |   bottom: 0; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   font-size: 0.95rem; | ||||||
|  |   font-weight: 600; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   pointer-events: none; | ||||||
|  |   color: #000; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .animatedNumberWrapper { | ||||||
|  |   margin-right: 4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoTopRow { display: contents; } | ||||||
|  | .partidoInfoWrapper { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   justify-content: center; | ||||||
|  |   min-width: 0; | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoNombre { | ||||||
|  |   font-weight: 600; | ||||||
|  |   font-size: 1rem; | ||||||
|  |   color: #212529; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   line-height: 1.2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoNombreNormal { | ||||||
|  |   font-size: 0.9rem; | ||||||
|  |   color: #212529; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   line-height: 1.2; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoNombre { | ||||||
|  |   font-size: 1rem; | ||||||
|  |   font-weight: 600; | ||||||
|  |   line-height: 1.1; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoStats { flex-shrink: 0; text-align: right; padding-left: 1rem; } | ||||||
|  | .partidoPorcentaje { font-size: 1.15rem; font-weight: 700; display: block; } | ||||||
|  |  | ||||||
|  | .partidoBarraBackground { | ||||||
|  |   height: 16px; | ||||||
|  |   background-color: #f0f0f0; | ||||||
|  |   border-radius: 4px; | ||||||
|  |   grid-column: 1 / 3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoBarraForeground { | ||||||
|  |   height: 100%; | ||||||
|  |   border-radius: 4px; | ||||||
|  |   transition: width 0.5s ease-in-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelEstadoRecuento { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-around; | ||||||
|  |   padding-bottom: 1rem; | ||||||
|  |   margin-bottom: 1rem; | ||||||
|  |   border-bottom: 1px solid #e0e0e0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .estadoItem { | ||||||
|  |   width: 95px; | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .estadoItem span { | ||||||
|  |   margin-top: 0.5rem; | ||||||
|  |   font-size: 0.85rem; | ||||||
|  |   color: #666; | ||||||
|  |   display: block; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- ESTILOS PARA MAPA --- */ | ||||||
|  | /* --- INICIO DE LA CORRECCIÓN --- */ | ||||||
|  | .mapaComponenteContainer {  | ||||||
|  |   width: 100%;  | ||||||
|  |   height: 100%;  | ||||||
|  |   position: relative; /* Esta línea es la que faltaba */ | ||||||
|  |   overflow: hidden;  | ||||||
|  | } | ||||||
|  | /* --- FIN DE LA CORRECCIÓN --- */ | ||||||
|  |  | ||||||
|  | .mapaRenderArea { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } | ||||||
|  |  | ||||||
|  | .mapaVolverBtn, | ||||||
|  | .zoomBtn { | ||||||
|  |   background-color: #ffffff; | ||||||
|  |   border: 1px solid #e0e0e0; /* Borde más sutil */ | ||||||
|  |   border-radius: 8px; /* Bordes más suaves */ | ||||||
|  |   box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); /* Sombra más pronunciada y moderna */ | ||||||
|  |   cursor: pointer; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   transition: all 0.2s ease-in-out; /* Transición suave para todos los efectos */ | ||||||
|  |   color: #333; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapaVolverBtn:hover, | ||||||
|  | .zoomBtn:hover:not(:disabled) { | ||||||
|  |   border-color: #007bff; /* Borde de acento */ | ||||||
|  |   color: #007bff; /* Icono/texto de acento */ | ||||||
|  |   transform: translateY(-2px); /* Efecto de "levantar" */ | ||||||
|  |   box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapaVolverBtn:active, | ||||||
|  | .zoomBtn:active:not(:disabled) { | ||||||
|  |   transform: translateY(0px); /* Botón "presionado" */ | ||||||
|  |   box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); /* Sombra interior */ | ||||||
|  |   background-color: #f8f9fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapaVolverBtn { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 10px; | ||||||
|  |   left: 10px; | ||||||
|  |   z-index: 10; | ||||||
|  |   padding: 8px 12px; | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :global(.rsm-zoomable-group) { transition: transform 0.75s ease-in-out; } | ||||||
|  | :global(.rsm-zoomable-group.panning) { transition: none; } | ||||||
|  |  | ||||||
|  | .panelMainContent.panelCollapsed .mapaColumn { flex: 1 1 100%; } | ||||||
|  |  | ||||||
|  | .panelMainContent.panelCollapsed .resultadosColumn { | ||||||
|  |   flex-basis: 0; | ||||||
|  |   min-width: 0; | ||||||
|  |   max-width: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelToggleBtn { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 50%; | ||||||
|  |   right: 10px; | ||||||
|  |   transform: translateY(-50%); | ||||||
|  |   z-index: 10; | ||||||
|  |   width: 30px; | ||||||
|  |   height: 50px; | ||||||
|  |   border: 1px solid #ccc; | ||||||
|  |   background-color: white; | ||||||
|  |   border-radius: 4px 0 0 4px; | ||||||
|  |   cursor: pointer; | ||||||
|  |   font-size: 1.3rem; | ||||||
|  |   font-weight: bold; | ||||||
|  |   color: #555; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); | ||||||
|  |   transition: background-color 0.2s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .panelToggleBtn:hover { background-color: #f0f0f0; } | ||||||
|  |  | ||||||
|  | :global(.rsm-geography) { | ||||||
|  |   stroke: #000000; | ||||||
|  |   stroke-width: 0.25px; | ||||||
|  |   outline: none; | ||||||
|  |   transition: filter 0.2s ease-in-out; | ||||||
|  | } | ||||||
|  | :global(.rsm-geography:not(.selected):hover) { | ||||||
|  |   filter: brightness(1.25); | ||||||
|  |   stroke: #ffffff; | ||||||
|  |   stroke-width: 0.25px; | ||||||
|  |   paint-order: stroke; | ||||||
|  | } | ||||||
|  | :global(.rsm-geography.selected) { | ||||||
|  |   stroke: #000000; | ||||||
|  |   stroke-width: 0.25px; | ||||||
|  |   filter: none; | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  | :global(.rsm-geography-faded), :global(.rsm-geography-faded-municipality) { | ||||||
|  |   opacity: 0.5; | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  | :global(.caba-comuna-geography) { | ||||||
|  |   stroke: #000000; | ||||||
|  |   stroke-width: 0.05px; | ||||||
|  | } | ||||||
|  | :global(.caba-comuna-geography:not(.selected):hover) { | ||||||
|  |   stroke: #000000; | ||||||
|  |   stroke-width: 0.055px; | ||||||
|  |   filter: brightness(1.25); | ||||||
|  | } | ||||||
|  | :global(.caba-comuna-geography.selected) { | ||||||
|  |   stroke: #000000; | ||||||
|  |   stroke-width: 0.075px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .transitionSpinner { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   background-color: rgba(255, 255, 255, 0.5); | ||||||
|  |   z-index: 20; | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  | } | ||||||
|  | .transitionSpinner::after { | ||||||
|  |   content: ''; | ||||||
|  |   width: 50px; | ||||||
|  |   height: 50px; | ||||||
|  |   border: 5px solid rgba(0, 0, 0, 0.2); | ||||||
|  |   border-top-color: #007bff; | ||||||
|  |   border-radius: 50%; | ||||||
|  |   animation: spin 1s linear infinite; | ||||||
|  | } | ||||||
|  | @keyframes spin { to { transform: rotate(360deg); } } | ||||||
|  |  | ||||||
|  | .cabaMagnifierContainer { position: absolute; height: auto; transform: translate(-50%, -50%); pointer-events: none; } | ||||||
|  | .cabaLupaSvg { width: 100%; height: auto; pointer-events: none; } | ||||||
|  | .cabaLupaInteractiveArea { pointer-events: all; cursor: pointer; filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25)); transition: transform 0.2s ease-in-out; } | ||||||
|  | .cabaLupaInteractiveArea:hover { filter: brightness(1.15); stroke: #ffffff; stroke-width: 0.25px; } | ||||||
|  |  | ||||||
|  | .skeletonFila div { | ||||||
|  |   background: #f6f7f8; | ||||||
|  |   background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%); | ||||||
|  |   background-repeat: no-repeat; | ||||||
|  |   background-size: 800px 104px; | ||||||
|  |   animation: shimmer 1s linear infinite; | ||||||
|  |   border-radius: 4px; | ||||||
|  | } | ||||||
|  | .skeletonLogo { width: 65px; height: 65px; } | ||||||
|  | .skeletonText { height: 1em; } | ||||||
|  | .skeletonBar { height: 20px; margin-top: 4px; } | ||||||
|  |  | ||||||
|  | .zoomControlsContainer { | ||||||
|  |   position: absolute; | ||||||
|  |   top: 10px; | ||||||
|  |   right: 10px; | ||||||
|  |   z-index: 30; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 8px; /* Un poco más de espacio */ | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Estilos específicos para los botones de zoom */ | ||||||
|  | .zoomBtn { | ||||||
|  |   width: 40px; | ||||||
|  |   height: 40px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .zoomIconWrapper svg { | ||||||
|  |   width: 22px; /* Iconos ligeramente más grandes */ | ||||||
|  |   height: 22px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Estilo para el botón deshabilitado */ | ||||||
|  | .zoomBtn:disabled, | ||||||
|  | .zoomBtn.disabled { /* Cubrimos ambos casos */ | ||||||
|  |   opacity: 0.6; | ||||||
|  |   cursor: not-allowed; | ||||||
|  |   background-color: #f8f9fa; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | :global(.map-locked .rsm-geography) { cursor: pointer; } | ||||||
|  | :global(.map-pannable .rsm-geography) { cursor: grab; } | ||||||
|  |  | ||||||
|  | .headerBottomRow { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  |   margin-top: 1rem; | ||||||
|  |   gap: 1rem; | ||||||
|  | } | ||||||
|  | .municipioSearchContainer { min-width: 280px; } | ||||||
|  |  | ||||||
|  | @media (max-width: 800px) { | ||||||
|  |   .panelNacionalContainer { display: flex; flex-direction: column; height: 100vh; padding: 0; border: none; border-radius: 0; } | ||||||
|  |   .panelHeader { flex-shrink: 0; padding: 1rem; border-radius: 0; } | ||||||
|  |   .panelMainContent { flex-grow: 1; position: relative; height: auto; min-height: 0; } | ||||||
|  |   .panelToggleBtn { display: none; } | ||||||
|  |   .headerTopRow { flex-direction: column; align-items: flex-start; gap: 1rem; } | ||||||
|  |   .categoriaSelectorContainer { width: 100%; } | ||||||
|  |   .mapaColumn, | ||||||
|  |   .resultadosColumn { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; } | ||||||
|  |   .mapaColumn { z-index: 10; } | ||||||
|  |   .resultadosColumn { padding: 1rem; overflow-y: auto; z-index: 15; } | ||||||
|  |   .panelMainContent.mobile-view-mapa .resultadosColumn { opacity: 0; visibility: hidden; pointer-events: none; } | ||||||
|  |   .panelMainContent.mobile-view-resultados .mapaColumn { opacity: 0; visibility: hidden; pointer-events: none; } | ||||||
|  |   .resultadosColumn { padding: 0.5rem; padding-bottom: 50px; } | ||||||
|  |   .mapaColumn .mapaComponenteContainer, .mapaColumn .mapaRenderArea { height: 100%; } | ||||||
|  |   .panelPartidosContainer { padding-bottom: 0; } | ||||||
|  |   .zoomControlsContainer, .mapaVolverBtn { top: 15px; } | ||||||
|  |   .headerBottomRow { flex-direction: column; align-items: stretch; gap: 1rem; } | ||||||
|  |   .municipioSearchContainer { min-width: 100%; } | ||||||
|  |  | ||||||
|  |   @media (max-width: 900px) and (orientation: landscape) { | ||||||
|  |     .panelMainContent { display: flex; flex-direction: row; position: static; height: 85vh; min-height: 400px; } | ||||||
|  |     .mapaColumn, | ||||||
|  |     .resultadosColumn { position: static; height: auto; width: auto; opacity: 1; visibility: visible; pointer-events: auto; flex: 3; overflow-y: auto; } | ||||||
|  |     .resultadosColumn { flex: 2; min-width: 300px; } | ||||||
|  |     .mobileResultsCardContainer { display: none; } | ||||||
|  |     .panelToggleBtn { display: flex; } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mobileResultsCardContainer { | ||||||
|  |   position: absolute; | ||||||
|  |   bottom: 0px; | ||||||
|  |   left: 50%; | ||||||
|  |   transform: translateX(-50%); | ||||||
|  |   z-index: 40; | ||||||
|  |   width: 95%; | ||||||
|  |   max-width: 450px; | ||||||
|  |   background-color: rgba(255, 255, 255, 0.95); | ||||||
|  |   border-radius: 12px; | ||||||
|  |   box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); | ||||||
|  |   backdrop-filter: blur(8px); | ||||||
|  |   -webkit-backdrop-filter: blur(8px); | ||||||
|  |   border: 1px solid rgba(0, 0, 0, 0.1); | ||||||
|  |   transition: all 0.3s ease-in-out; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  | .mobileResultsCardContainer.view-resultados .collapsibleSection { display: none; } | ||||||
|  | .mobileResultsCardContainer.view-resultados .mobileCardViewToggle { border-top: none; } | ||||||
|  | .collapsibleSection { display: flex; flex-direction: column; } | ||||||
|  | .mobileResultsHeader { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; cursor: pointer; } | ||||||
|  | .mobileResultsHeader .headerInfo { display: flex; align-items: baseline; gap: 12px; } | ||||||
|  | .mobileResultsHeader .headerInfo h4 { margin: 0; font-size: 1.2rem; font-weight: 700; } | ||||||
|  | .mobileResultsHeader .headerInfo .headerActionText { font-size: 0.8rem; color: #6c757d; font-weight: 500; text-transform: uppercase; } | ||||||
|  | .mobileResultsHeader .headerToggleIcon { font-size: 1.5rem; color: #007bff; transition: transform 0.3s; } | ||||||
|  | .mobileResultsContent { max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; padding: 0 15px; border-top: 1px solid transparent; } | ||||||
|  | .mobileResultsCardContainer.expanded .mobileResultsContent { max-height: 500px; opacity: 1; padding: 5px 15px 15px 15px; border-top-color: #e0e0e0; } | ||||||
|  | .mobileResultRow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; border-left: 4px solid; padding-left: 8px; } | ||||||
|  | .mobileResultRow:last-child { border-bottom: none; } | ||||||
|  | .mobileResultLogo { flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; } | ||||||
|  | .mobileResultLogo img { width: 100%; height: 100%; border-radius: 8px; } | ||||||
|  | .mobileResultInfo { flex-grow: 1; min-width: 0; } | ||||||
|  | .mobileResultPartyName { display: block; font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | ||||||
|  | .mobileResultCandidateName { display: block; font-size: 0.75rem; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | ||||||
|  | .mobileResultStats { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; } | ||||||
|  | .mobileResultStats strong { font-size: 0.95rem; font-weight: 700; } | ||||||
|  | .mobileResultStats span { font-size: 0.7rem; color: #6c757d; } | ||||||
|  | .noResultsText { padding: 1rem; text-align: center; color: #6c757d; font-size: 0.9rem; } | ||||||
|  | .mobileCardViewToggle { display: flex; padding: 5px; background-color: rgba(230, 230, 230, 0.6); border-top: 1px solid rgba(0, 0, 0, 0.08); } | ||||||
|  | .mobileCardViewToggle .toggleBtn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 15px; border: none; background-color: transparent; border-radius: 25px; cursor: pointer; font-size: 1rem; font-weight: 500; color: #555; transition: all 0.2s ease-in-out; } | ||||||
|  | .mobileCardViewToggle .toggleBtn.active { background-color: #007bff; color: white; box-shadow: 0 2px 5px rgba(0, 123, 255, 0.2); } | ||||||
|  |  | ||||||
|  | @media (max-width: 380px) { | ||||||
|  |   .mobileResultsHeader { padding: 4px 10px; } | ||||||
|  |   .mobileResultsHeader .headerInfo h4 { font-size: 0.75rem; text-transform: uppercase; } | ||||||
|  |   .mobileResultsHeader .headerInfo .headerActionText { font-size: 0.7rem; } | ||||||
|  |   .mobileCardViewToggle .toggleBtn { padding: 6px 10px; font-size: 0.8rem; } | ||||||
|  | } | ||||||
| @@ -0,0 +1,283 @@ | |||||||
|  | // src/features/legislativas/nacionales/PanelNacionalWidget.tsx | ||||||
|  |  | ||||||
|  | import { useMemo, useState, Suspense, useEffect } from 'react'; | ||||||
|  | import { useSuspenseQuery } from '@tanstack/react-query'; | ||||||
|  | import { getPanelElectoral } from '../../../apiService'; | ||||||
|  | import { MapaNacional } from './components/MapaNacional'; | ||||||
|  | import { PanelResultados } from './components/PanelResultados'; | ||||||
|  | import { Breadcrumbs } from './components/Breadcrumbs'; | ||||||
|  | import { MunicipioSearch } from './components/MunicipioSearch'; | ||||||
|  | import styles from './PanelNacional.module.css'; | ||||||
|  | import Select from 'react-select'; | ||||||
|  | import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types'; | ||||||
|  | import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi'; | ||||||
|  | import { useMediaQuery } from './hooks/useMediaQuery'; | ||||||
|  | import toast, { Toaster } from 'react-hot-toast'; | ||||||
|  | import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||||
|  | import { assetBaseUrl } from '../../../apiService'; | ||||||
|  | import { useQueryClient } from '@tanstack/react-query'; | ||||||
|  |  | ||||||
|  | // --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO --- | ||||||
|  | const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||||
|  |  | ||||||
|  | const ResultRow = ({ partido }: { partido: ResultadoTicker }) => ( | ||||||
|  |   <div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}> | ||||||
|  |     <div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}> | ||||||
|  |       <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> | ||||||
|  |     </div> | ||||||
|  |     <div className={styles.mobileResultInfo}> | ||||||
|  |       {partido.nombreCandidato ? ( | ||||||
|  |         <> | ||||||
|  |           <span className={styles.mobileResultPartyName}>{partido.nombreCandidato}</span> | ||||||
|  |           <span className={styles.mobileResultCandidateName}>{partido.nombreCorto || partido.nombre}</span> | ||||||
|  |         </> | ||||||
|  |       ) : ( | ||||||
|  |         <span className={styles.mobileResultPartyName}>{partido.nombreCorto || partido.nombre}</span> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |     <div className={styles.mobileResultStats}> | ||||||
|  |       <strong>{formatPercent(partido.porcentaje)}</strong> | ||||||
|  |       <span>{partido.votos.toLocaleString('es-AR')}</span> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | // --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL --- | ||||||
|  | interface MobileResultsCardProps { | ||||||
|  |   eleccionId: number; | ||||||
|  |   ambitoId: string | null; | ||||||
|  |   categoriaId: number; | ||||||
|  |   ambitoNombre: string; | ||||||
|  |   ambitoNivel: 'pais' | 'provincia' | 'municipio'; | ||||||
|  |   mobileView: 'mapa' | 'resultados'; | ||||||
|  |   setMobileView: (view: 'mapa' | 'resultados') => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const MobileResultsCard = ({ | ||||||
|  |   eleccionId, ambitoId, categoriaId, ambitoNombre, ambitoNivel, mobileView, setMobileView | ||||||
|  | }: MobileResultsCardProps) => { | ||||||
|  |  | ||||||
|  |   const [isExpanded, setIsExpanded] = useState(false); | ||||||
|  |  | ||||||
|  |   const { data } = useSuspenseQuery<PanelElectoralDto>({ | ||||||
|  |     queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId, ambitoNivel], | ||||||
|  |     queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId, ambitoNivel), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setIsExpanded(ambitoNivel === 'municipio'); | ||||||
|  |   }, [ambitoNivel]); | ||||||
|  |  | ||||||
|  |   const topResults = data.resultadosPanel.slice(0, 3); | ||||||
|  |  | ||||||
|  |   if (topResults.length === 0 && ambitoNivel === 'pais') { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const cardClasses = [ | ||||||
|  |     styles.mobileResultsCardContainer, | ||||||
|  |     isExpanded ? styles.expanded : '', | ||||||
|  |     styles[`view-${mobileView}`] | ||||||
|  |   ].join(' '); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={cardClasses}> | ||||||
|  |       <div className={styles.collapsibleSection}> | ||||||
|  |         <div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}> | ||||||
|  |           <div className={styles.headerInfo}> | ||||||
|  |             <h4>{ambitoNombre}</h4> | ||||||
|  |             <span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span> | ||||||
|  |           </div> | ||||||
|  |           <div className={styles.headerToggleIcon}> | ||||||
|  |             {isExpanded ? <FiChevronDown /> : <FiChevronUp />} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.mobileResultsContent}> | ||||||
|  |           {topResults.length > 0 ? ( | ||||||
|  |             topResults.map(partido => <ResultRow key={partido.id} partido={partido} />) | ||||||
|  |           ) : ( | ||||||
|  |             <p className={styles.noResultsText}>No hay resultados para esta selección.</p> | ||||||
|  |           )} | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <div className={styles.mobileCardViewToggle}> | ||||||
|  |         <button | ||||||
|  |           className={`${styles.toggleBtn} ${mobileView === 'mapa' ? styles.active : ''}`} | ||||||
|  |           onClick={() => setMobileView('mapa')} | ||||||
|  |         > | ||||||
|  |           <FiMap /> | ||||||
|  |           <span>Mapa</span> | ||||||
|  |         </button> | ||||||
|  |         <button | ||||||
|  |           className={`${styles.toggleBtn} ${mobileView === 'resultados' ? styles.active : ''}`} | ||||||
|  |           onClick={() => setMobileView('resultados')} | ||||||
|  |         > | ||||||
|  |           <FiList /> | ||||||
|  |           <span>Detalles</span> | ||||||
|  |         </button> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // --- WIDGET PRINCIPAL --- | ||||||
|  | interface PanelNacionalWidgetProps { | ||||||
|  |   eleccionId: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type AmbitoState = { | ||||||
|  |   id: string | null; | ||||||
|  |   nivel: 'pais' | 'provincia' | 'municipio'; | ||||||
|  |   nombre: string; | ||||||
|  |   provinciaNombre?: string; | ||||||
|  |   provinciaDistritoId?: string | null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const CATEGORIAS_NACIONALES = [ | ||||||
|  |   { value: 3, label: 'Diputados Nacionales' }, | ||||||
|  |   { value: 2, label: 'Senadores Nacionales' }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => { | ||||||
|  |   const { data } = useSuspenseQuery<PanelElectoralDto>({ | ||||||
|  |     queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel], | ||||||
|  |     queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (data.sinDatos) { | ||||||
|  |     return ( | ||||||
|  |       <div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}> | ||||||
|  |         <h4>Sin Resultados Detallados</h4> | ||||||
|  |         <p>Aún no hay datos disponibles para esta selección.</p> | ||||||
|  |         <p>Por favor, intente de nuevo más tarde.</p> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => { | ||||||
|  |   const queryClient = useQueryClient(); | ||||||
|  |   const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null }); | ||||||
|  |   const [categoriaId, setCategoriaId] = useState<number>(3); | ||||||
|  |   const [isPanelOpen, setIsPanelOpen] = useState(true); | ||||||
|  |   const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa'); | ||||||
|  |   const isMobile = useMediaQuery('(max-width: 800px)'); | ||||||
|  |  | ||||||
|  |   const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => { | ||||||
|  |     if (nuevoNivel === 'municipio') { | ||||||
|  |       toast.promise( | ||||||
|  |         queryClient.invalidateQueries({ queryKey: ['panelElectoral', eleccionId, nuevoAmbitoId, categoriaId, nuevoNivel] }), | ||||||
|  |         { | ||||||
|  |           loading: `Cargando datos de ${nuevoNombre}...`, | ||||||
|  |           error: <b>No se pudieron cargar los datos.</b>, | ||||||
|  |         } | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     setAmbitoActual(prev => ({ | ||||||
|  |       id: nuevoAmbitoId, | ||||||
|  |       nivel: nuevoNivel, | ||||||
|  |       nombre: nuevoNombre, | ||||||
|  |       provinciaNombre: nuevoNivel === 'municipio' ? prev.provinciaNombre : (nuevoNivel === 'provincia' ? nuevoNombre : undefined), | ||||||
|  |       provinciaDistritoId: nuevoNivel === 'provincia' ? nuevoAmbitoId : prev.provinciaDistritoId | ||||||
|  |     })); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleResetToPais = () => { | ||||||
|  |     setAmbitoActual({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleVolverAProvincia = () => { | ||||||
|  |     if (ambitoActual.provinciaDistritoId && ambitoActual.provinciaNombre) { | ||||||
|  |       setAmbitoActual({ | ||||||
|  |         id: ambitoActual.provinciaDistritoId, | ||||||
|  |         nivel: 'provincia', | ||||||
|  |         nombre: ambitoActual.provinciaNombre, | ||||||
|  |         provinciaDistritoId: ambitoActual.provinciaDistritoId, | ||||||
|  |         provinciaNombre: ambitoActual.provinciaNombre, | ||||||
|  |       }); | ||||||
|  |     } else { | ||||||
|  |       handleResetToPais(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const selectedCategoria = useMemo(() => | ||||||
|  |     CATEGORIAS_NACIONALES.find(c => c.value === categoriaId), | ||||||
|  |     [categoriaId] | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const mainContentClasses = [ | ||||||
|  |     styles.panelMainContent, | ||||||
|  |     !isPanelOpen ? styles.panelCollapsed : '', | ||||||
|  |     isMobile ? styles[`mobile-view-${mobileView}`] : '' | ||||||
|  |   ].join(' '); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.panelNacionalContainer}> | ||||||
|  |       <Toaster | ||||||
|  |         position="bottom-center" | ||||||
|  |         containerClassName={styles.widgetToasterContainer} | ||||||
|  |       /> | ||||||
|  |       <header className={styles.panelHeader}> | ||||||
|  |         <div className={styles.headerTopRow}> | ||||||
|  |           <Select | ||||||
|  |             options={CATEGORIAS_NACIONALES} | ||||||
|  |             value={selectedCategoria} | ||||||
|  |             onChange={(option) => option && setCategoriaId(option.value)} | ||||||
|  |             classNamePrefix="categoriaSelector" | ||||||
|  |             className={styles.categoriaSelectorContainer} | ||||||
|  |             isSearchable={false} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.headerBottomRow}> | ||||||
|  |           <Breadcrumbs | ||||||
|  |             nivel={ambitoActual.nivel} | ||||||
|  |             nombreAmbito={ambitoActual.nombre} | ||||||
|  |             nombreProvincia={ambitoActual.provinciaNombre} | ||||||
|  |             onReset={handleResetToPais} | ||||||
|  |             onVolverProvincia={handleVolverAProvincia} | ||||||
|  |           /> | ||||||
|  |           {ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && ( | ||||||
|  |             <MunicipioSearch | ||||||
|  |               distritoId={ambitoActual.provinciaDistritoId} | ||||||
|  |               onMunicipioSelect={(municipioId, municipioNombre) => | ||||||
|  |                 handleAmbitoSelect(municipioId, 'municipio', municipioNombre) | ||||||
|  |               } | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |         </div> | ||||||
|  |       </header> | ||||||
|  |       <main className={mainContentClasses}> | ||||||
|  |         <div className={styles.mapaColumn}> | ||||||
|  |           <button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '›' : '‹'} </button> | ||||||
|  |  | ||||||
|  |           <Suspense fallback={<div className={styles.spinner} />}> | ||||||
|  |             <MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} /> | ||||||
|  |           </Suspense> | ||||||
|  |         </div> | ||||||
|  |         <div className={styles.resultadosColumn}> | ||||||
|  |           <Suspense fallback={<div className={styles.spinner} />}> | ||||||
|  |             <PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} /> | ||||||
|  |           </Suspense> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <Suspense fallback={null}> | ||||||
|  |           {isMobile && ( | ||||||
|  |             <MobileResultsCard | ||||||
|  |               eleccionId={eleccionId} | ||||||
|  |               ambitoId={ambitoActual.id} | ||||||
|  |               categoriaId={categoriaId} | ||||||
|  |               ambitoNombre={ambitoActual.nombre} | ||||||
|  |               ambitoNivel={ambitoActual.nivel} | ||||||
|  |               mobileView={mobileView} | ||||||
|  |               setMobileView={setMobileView} | ||||||
|  |             /> | ||||||
|  |           )} | ||||||
|  |         </Suspense> | ||||||
|  |       </main> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,299 @@ | |||||||
|  | /* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.module.css */ | ||||||
|  |  | ||||||
|  | /* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */ | ||||||
|  | .cardsWidgetContainer, | ||||||
|  | .cardsWidgetContainer * { | ||||||
|  |     font-family: "Roboto", system-ui, sans-serif !important; | ||||||
|  |     box-sizing: border-box; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Contenedor Principal del Widget y Variables --- */ | ||||||
|  | .cardsWidgetContainer { | ||||||
|  |     --card-border-color: #e0e0e0; | ||||||
|  |     --card-bg-color: #ffffff; | ||||||
|  |     --card-header-bg-color: #e6f1fd; | ||||||
|  |     --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); | ||||||
|  |     --text-primary: #212529; | ||||||
|  |     --text-secondary: #6c757d; | ||||||
|  |     --primary-accent-color: #007bff; | ||||||
|  |      | ||||||
|  |     width: 100%; | ||||||
|  |     max-width: 1200px; | ||||||
|  |     margin: 2rem auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cardsWidgetContainer h2 { | ||||||
|  |     font-size: 1.75rem; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     margin-bottom: 1.5rem; | ||||||
|  |     padding-bottom: 0.5rem; | ||||||
|  |     border-bottom: 1px solid var(--card-border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Grilla de Tarjetas --- */ | ||||||
|  | .cardsGrid { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); | ||||||
|  |     gap: 1.5rem; | ||||||
|  |     align-items: start; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Tarjeta Individual --- */ | ||||||
|  | .provinciaCard { | ||||||
|  |     background-color: var(--card-bg-color); | ||||||
|  |     border: 1px solid var(--card-border-color); | ||||||
|  |     border-radius: 8px; | ||||||
|  |     box-shadow: var(--card-shadow); | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     overflow: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Cabecera de la Tarjeta --- */ | ||||||
|  | .cardHeader { | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: center; | ||||||
|  |     background-color: var(--card-header-bg-color); | ||||||
|  |     padding: 0.75rem 1rem; | ||||||
|  |     border-bottom: 1px solid var(--card-border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .headerInfo h3 { | ||||||
|  |     margin: 0; | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |     color: var(--text-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .headerInfo span { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     text-transform: uppercase; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .headerMap { | ||||||
|  |     width: 90px; | ||||||
|  |     height: 90px; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     border-radius: 4px; | ||||||
|  |     overflow: hidden; | ||||||
|  |     background-color: #f7fbff; | ||||||
|  |     padding: 0.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapSvgContainer, .mapPlaceholder { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     border-radius: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapSvgContainer svg { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     object-fit: contain; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mapPlaceholder.error { | ||||||
|  |     background-color: #f8d7da; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Cuerpo de la Tarjeta --- */ | ||||||
|  | .cardBody { | ||||||
|  |     padding: 0.5rem 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoRow:last-child { | ||||||
|  |     border-bottom: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoRow { | ||||||
|  |     display: flex; | ||||||
|  |     align-items: center; | ||||||
|  |     gap: 0.75rem; | ||||||
|  |     padding: 0.75rem 0; | ||||||
|  |     border-bottom: 1px solid #f0f0f0; | ||||||
|  |     border-left: 5px solid; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     padding-left: 0.75rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoFotoWrapper { | ||||||
|  |     width: 60px; | ||||||
|  |     height: 60px; | ||||||
|  |     border-radius: 12px; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     background-color: #e9ecef; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoFoto { | ||||||
|  |     width: 100%; | ||||||
|  |     height: 100%; | ||||||
|  |     border-radius: 12px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoData { | ||||||
|  |     flex-grow: 1; | ||||||
|  |     min-width: 0; | ||||||
|  |     margin-right: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoNombre { | ||||||
|  |     font-weight: 700; | ||||||
|  |     font-size: 0.95rem; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     display: block; | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoPartido { | ||||||
|  |     font-size: 0.75rem; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     display: block; | ||||||
|  |     margin-bottom: 0.3rem; | ||||||
|  |     text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .progressBarContainer { | ||||||
|  |     height: 16px; | ||||||
|  |     background-color: #e9ecef; | ||||||
|  |     border-radius: 3px; | ||||||
|  |     overflow: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .progressBar { | ||||||
|  |     height: 100%; | ||||||
|  |     border-radius: 3px; | ||||||
|  |     transition: width 0.5s ease-out; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoStats { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: flex-end; | ||||||
|  |     text-align: right; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     padding-left: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .statsPercent { | ||||||
|  |     font-weight: 700; | ||||||
|  |     font-size: 1.1rem; | ||||||
|  |     color: var(--text-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .statsVotos { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .statsBancas { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: column; | ||||||
|  |     align-items: center; | ||||||
|  |     justify-content: center; | ||||||
|  |     flex-shrink: 0; | ||||||
|  |     border: 1px solid var(--card-border-color); | ||||||
|  |     border-radius: 6px; | ||||||
|  |     padding: 0.25rem 0.5rem; | ||||||
|  |     margin-left: 0.75rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |     font-size: 1.2rem; | ||||||
|  |     color: var(--primary-accent-color); | ||||||
|  |     min-width: 50px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .statsBancas span { | ||||||
|  |     font-size: 0.65rem; | ||||||
|  |     font-weight: 500; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     margin-top: -4px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Pie de la Tarjeta --- */ | ||||||
|  | .cardFooter { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: repeat(3, 1fr); | ||||||
|  |     background-color: var(--card-header-bg-color); | ||||||
|  |     border-top: 1px solid var(--card-border-color); | ||||||
|  |     padding: 0.75rem 0; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cardFooter div { | ||||||
|  |     border-right: 1px solid var(--card-border-color); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cardFooter div:last-child { | ||||||
|  |     border-right: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cardFooter span { | ||||||
|  |     display: block; | ||||||
|  |     font-size: 0.75rem; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .cardFooter strong { | ||||||
|  |     font-size: 1rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |     color: var(--text-primary); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- Media Query para Móvil --- */ | ||||||
|  | @media (max-width: 480px) { | ||||||
|  |     .cardsGrid { | ||||||
|  |         grid-template-columns: 1fr; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     .cardHeader { | ||||||
|  |         padding: 0.5rem; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     .headerInfo h3 { | ||||||
|  |         font-size: 1rem; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .candidatoPartido.mainTitle { | ||||||
|  |     font-size: 0.95rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |     color: var(--text-primary); | ||||||
|  |     text-transform: none; | ||||||
|  |     margin-bottom: 0.3rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */ | ||||||
|  | .categoriaBloque { | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaBloque + .categoriaBloque { | ||||||
|  |     border-top: 1px dashed var(--card-border-color); | ||||||
|  |     margin-top: 1rem; | ||||||
|  |     padding-top: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaTitulo { | ||||||
|  |     font-size: 0.8rem; | ||||||
|  |     color: var(--text-secondary); | ||||||
|  |     text-transform: uppercase; | ||||||
|  |     text-align: center; | ||||||
|  |     margin: 0 0 1rem 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaBloque .cardFooter { | ||||||
|  |     grid-template-columns: repeat(3, 1fr); | ||||||
|  |     background-color: transparent; | ||||||
|  |     border-top: 1px solid var(--card-border-color); | ||||||
|  |     padding: 0.75rem 0; | ||||||
|  |     margin-top: 0.75rem; | ||||||
|  |     text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaBloque .cardFooter div { | ||||||
|  |     border-right: 1px solid var(--card-border-color); | ||||||
|  | } | ||||||
|  | .categoriaBloque .cardFooter div:last-child { | ||||||
|  |     border-right: none; | ||||||
|  | } | ||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | // src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.tsx | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getResumenPorProvincia } from '../../../apiService'; | ||||||
|  | import { ProvinciaCard } from './components/ProvinciaCard'; | ||||||
|  | import styles from './ResultadosNacionalesCardsWidget.module.css'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  |     eleccionId: number; | ||||||
|  |     focoDistritoId?: string; | ||||||
|  |     focoCategoriaId?: number; | ||||||
|  |     cantidadResultados?: number; | ||||||
|  |     mostrarBancas?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const ResultadosNacionalesCardsWidget = ({  | ||||||
|  |     eleccionId,  | ||||||
|  |     focoDistritoId,  | ||||||
|  |     focoCategoriaId,  | ||||||
|  |     cantidadResultados, | ||||||
|  |     mostrarBancas = false | ||||||
|  | }: Props) => { | ||||||
|  |      | ||||||
|  |     const { data, isLoading, error } = useQuery({ | ||||||
|  |         queryKey: ['resumenPorProvincia', eleccionId, focoDistritoId, focoCategoriaId, cantidadResultados], | ||||||
|  |          | ||||||
|  |         queryFn: () => getResumenPorProvincia(eleccionId, { | ||||||
|  |             focoDistritoId, | ||||||
|  |             focoCategoriaId, | ||||||
|  |             cantidadResultados | ||||||
|  |         }), | ||||||
|  |         refetchInterval: 180000, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     if (isLoading) return <div>Cargando resultados por provincia...</div>; | ||||||
|  |     if (error) return <div>Error al cargar los datos.</div>; | ||||||
|  |     if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div> | ||||||
|  |  | ||||||
|  |     return ( | ||||||
|  |         <section className={styles.cardsWidgetContainer}> | ||||||
|  |             <div className={styles.cardsGrid}> | ||||||
|  |                 {data?.map(provinciaData => ( | ||||||
|  |                     <ProvinciaCard  | ||||||
|  |                         key={provinciaData.provinciaId}  | ||||||
|  |                         data={provinciaData}  | ||||||
|  |                         mostrarBancas={mostrarBancas}  | ||||||
|  |                     /> | ||||||
|  |                 ))} | ||||||
|  |             </div> | ||||||
|  |         </section> | ||||||
|  |     ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,107 @@ | |||||||
|  | /* src/components/widgets/ResumenNacionalWidget.module.css */ | ||||||
|  | .widgetContainer { | ||||||
|  |   font-family: 'Roboto', sans-serif; | ||||||
|  |   border: 1px solid #e0e0e0; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   padding: 1.5rem; | ||||||
|  |   max-width: 500px; | ||||||
|  |   margin: 2rem auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header { | ||||||
|  |   text-align: center; | ||||||
|  |   padding-bottom: 1rem; | ||||||
|  |   margin-bottom: 1rem; | ||||||
|  |   border-bottom: 1px solid #eee; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header h3 { | ||||||
|  |   margin: 0; | ||||||
|  |   font-size: 1.8rem; | ||||||
|  |   font-weight: 400; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .subHeader { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  |   margin-bottom: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .subHeader h4 { | ||||||
|  |   margin: 0; | ||||||
|  |   font-size: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelector { | ||||||
|  |   min-width: 230px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable { | ||||||
|  |   width: 100%; | ||||||
|  |   border-collapse: collapse; | ||||||
|  |   border-spacing: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable thead { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable td { | ||||||
|  |   padding: 3px 8px; | ||||||
|  |   text-align: left; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .provinciaBlock { | ||||||
|  |   border-top: 1px solid #e0e0e0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .provinciaBlock:first-child { | ||||||
|  |   border-top: none; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .provinciaNombre { | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 1rem; | ||||||
|  |   text-transform: uppercase; | ||||||
|  |   color: #333; | ||||||
|  |   padding-top: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .provinciaEscrutado { | ||||||
|  |   font-size: 0.8rem; | ||||||
|  |   color: #666; | ||||||
|  |   text-align: right; | ||||||
|  |   white-space: nowrap; | ||||||
|  |   padding-top: 1rem; | ||||||
|  |   width: 1%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoNombre { | ||||||
|  |   font-size: 0.9rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .partidoPorcentaje { | ||||||
|  |   text-align: right; | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 0.95rem; | ||||||
|  |   width: 1%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- INICIO DE ESTILOS PARA MÓVILES --- */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .subHeader { | ||||||
|  |     flex-direction: column; /* Apila el título y el selector */ | ||||||
|  |     align-items: center;   /* Centra los elementos */ | ||||||
|  |     gap: 0.75rem;          /* Añade espacio entre ellos */ | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .subHeader h4 { | ||||||
|  |     text-align: center; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .categoriaSelector { | ||||||
|  |     width: 100%;           /* Hace que el selector ocupe todo el ancho */ | ||||||
|  |     min-width: unset;      /* Elimina el ancho mínimo que interfiere */ | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,105 @@ | |||||||
|  | // src/components/widgets/ResumenNacionalWidget.tsx | ||||||
|  | import { useState, useMemo } from 'react'; | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import Select from 'react-select'; | ||||||
|  | import { getResumenNacionalPorProvincia } from '../../../apiService'; | ||||||
|  | import styles from './ResumenNacionalWidget.module.css'; | ||||||
|  |  | ||||||
|  | const ELECCION_ID = 2; // Exclusivo para elecciones nacionales | ||||||
|  | const CATEGORIAS_NACIONALES = [ | ||||||
|  |   { value: 3, label: 'Diputados Nacionales' }, | ||||||
|  |   { value: 2, label: 'Senadores Nacionales' }, | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | // 1. Mapa para definir el orden y número de cada provincia según el PDF | ||||||
|  | const PROVINCE_ORDER_MAP: Record<string, number> = { | ||||||
|  |   '02': 1,  // Buenos Aires | ||||||
|  |   '03': 2,  // Catamarca | ||||||
|  |   '06': 3,  // Chaco | ||||||
|  |   '07': 4,  // Chubut | ||||||
|  |   '04': 5,  // Córdoba | ||||||
|  |   '05': 6,  // Corrientes | ||||||
|  |   '08': 7,  // Entre Ríos | ||||||
|  |   '09': 8,  // Formosa | ||||||
|  |   '10': 9,  // Jujuy | ||||||
|  |   '11': 10, // La Pampa | ||||||
|  |   '12': 11, // La Rioja | ||||||
|  |   '13': 12, // Mendoza | ||||||
|  |   '14': 13, // Misiones | ||||||
|  |   '15': 14, // Neuquén | ||||||
|  |   '16': 15, // Río Negro | ||||||
|  |   '17': 16, // Salta | ||||||
|  |   '18': 17, // San Juan | ||||||
|  |   '19': 18, // San Luis | ||||||
|  |   '20': 19, // Santa Cruz | ||||||
|  |   '21': 20, // Santa Fe | ||||||
|  |   '22': 21, // Santiago del Estero | ||||||
|  |   '23': 22, // Tierra del Fuego | ||||||
|  |   '24': 23, // Tucumán | ||||||
|  |   '01': 24, // CABA | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export const ResumenNacionalWidget = () => { | ||||||
|  |   const [categoria, setCategoria] = useState(CATEGORIAS_NACIONALES[0]); | ||||||
|  |  | ||||||
|  |   const { data, isLoading, error } = useQuery({ | ||||||
|  |     queryKey: ['resumenNacional', ELECCION_ID, categoria.value], | ||||||
|  |     queryFn: () => getResumenNacionalPorProvincia(ELECCION_ID, categoria.value), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   // 2. Ordenar los datos de la API usando el mapa de ordenamiento | ||||||
|  |   const sortedData = useMemo(() => { | ||||||
|  |     if (!data) return []; | ||||||
|  |     return [...data].sort((a, b) => { | ||||||
|  |       const orderA = PROVINCE_ORDER_MAP[a.provinciaId] ?? 99; | ||||||
|  |       const orderB = PROVINCE_ORDER_MAP[b.provinciaId] ?? 99; | ||||||
|  |       return orderA - orderB; | ||||||
|  |     }); | ||||||
|  |   }, [data]); | ||||||
|  |  | ||||||
|  |   const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.widgetContainer}> | ||||||
|  |       <div className={styles.subHeader}> | ||||||
|  |         <h4>{categoria.label}</h4> | ||||||
|  |         <Select | ||||||
|  |           className={styles.categoriaSelector} | ||||||
|  |           options={CATEGORIAS_NACIONALES} | ||||||
|  |           value={categoria} | ||||||
|  |           onChange={(opt) => setCategoria(opt!)} | ||||||
|  |           isSearchable={false} | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       {isLoading && <p>Cargando resumen nacional...</p>} | ||||||
|  |       {error && <p style={{ color: 'red' }}>Error al cargar los datos.</p>} | ||||||
|  |       {sortedData && ( | ||||||
|  |         <table className={styles.resultsTable}> | ||||||
|  |           <thead> | ||||||
|  |             <tr> | ||||||
|  |               <th>Concepto</th> | ||||||
|  |               <th>Valor</th> | ||||||
|  |             </tr> | ||||||
|  |           </thead> | ||||||
|  |           {sortedData.map((provincia) => ( | ||||||
|  |             <tbody key={provincia.provinciaId} className={styles.provinciaBlock}> | ||||||
|  |               <tr> | ||||||
|  |                 {/* 3. Añadir el número antes del nombre */} | ||||||
|  |                 <td className={styles.provinciaNombre}>{`${PROVINCE_ORDER_MAP[provincia.provinciaId]}- ${provincia.provinciaNombre}`}</td> | ||||||
|  |                 <td className={styles.provinciaEscrutado}>ESCR. {formatPercent(provincia.porcentajeEscrutado)}</td> | ||||||
|  |               </tr> | ||||||
|  |               {provincia.resultados.map((partido, index) => ( | ||||||
|  |                 <tr key={index}> | ||||||
|  |                   <td className={styles.partidoNombre}>{partido.nombre}</td> | ||||||
|  |                   <td className={styles.partidoPorcentaje}>{formatPercent(partido.porcentaje)}</td> | ||||||
|  |                 </tr> | ||||||
|  |               ))} | ||||||
|  |             </tbody> | ||||||
|  |           ))} | ||||||
|  |         </table> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,52 @@ | |||||||
|  | // src/features/legislativas/nacionales/TablaConurbanoWidget.tsx | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getTablaConurbano } from '../../../apiService'; | ||||||
|  | import styles from './TablaResultadosWidget.module.css'; | ||||||
|  |  | ||||||
|  | export const TablaConurbanoWidget = () => { | ||||||
|  |   const ELECCION_ID = 2; | ||||||
|  |  | ||||||
|  |   const { data, isLoading, error } = useQuery({ | ||||||
|  |     queryKey: ['tablaConurbano', ELECCION_ID], | ||||||
|  |     queryFn: () => getTablaConurbano(ELECCION_ID), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const formatPercent = (num: number) => `${num.toFixed(2)}%`; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.widgetContainer}> | ||||||
|  |       <div className={styles.header}> | ||||||
|  |         <h3>Diputados Nacionales</h3> | ||||||
|  |       </div> | ||||||
|  |       {isLoading && <p>Cargando resultados...</p>} | ||||||
|  |       {error && <p>Error al cargar los datos.</p>} | ||||||
|  |       {data && ( | ||||||
|  |         <table className={styles.resultsTable}> | ||||||
|  |           <thead> | ||||||
|  |             <tr> | ||||||
|  |               <th>Distrito</th> | ||||||
|  |               <th>1ra Fuerza</th> | ||||||
|  |               <th>%</th> | ||||||
|  |               <th>2da Fuerza</th> | ||||||
|  |               <th>%</th> | ||||||
|  |             </tr> | ||||||
|  |           </thead> | ||||||
|  |           <tbody> | ||||||
|  |             {data.map((fila, index) => ( | ||||||
|  |               <tr key={fila.ambitoId}> | ||||||
|  |                 <td className={styles.distritoCell}> | ||||||
|  |                   <span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre} | ||||||
|  |                 </td> | ||||||
|  |                 <td className={styles.fuerzaCell}>{fila.fuerza1Display}</td> | ||||||
|  |                 <td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td> | ||||||
|  |                 <td className={styles.fuerzaCell}>{fila.fuerza2Display}</td> | ||||||
|  |                 <td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td> | ||||||
|  |               </tr> | ||||||
|  |             ))} | ||||||
|  |           </tbody> | ||||||
|  |         </table> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,176 @@ | |||||||
|  | /* src/features/legislativas/nacionales/TablaResultadosWidget.module.css */ | ||||||
|  | .widgetContainer { | ||||||
|  |   font-family: 'Roboto', sans-serif; | ||||||
|  |   max-width: 900px; | ||||||
|  |   margin: 2rem auto; | ||||||
|  |   border: 1px solid #e0e0e0; | ||||||
|  |   border-radius: 8px; | ||||||
|  |   padding: 1rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   align-items: center; | ||||||
|  |   margin-bottom: 1rem; | ||||||
|  |   padding-bottom: 1rem; | ||||||
|  |   border-bottom: 1px solid #e0e0e0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .header h3 { | ||||||
|  |   margin: 0; | ||||||
|  |   font-size: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .categoriaSelector { | ||||||
|  |   min-width: 250px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable { | ||||||
|  |   width: 100%; | ||||||
|  |   border-collapse: separate; | ||||||
|  |   border-spacing: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable th, | ||||||
|  | .resultsTable td { | ||||||
|  |   padding: 0.75rem; | ||||||
|  |   text-align: left; | ||||||
|  |   border-bottom: 1px solid #f0f0f0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .resultsTable th { | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 0.8rem; | ||||||
|  |   text-transform: uppercase; | ||||||
|  |   color: #6c757d; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .distritoCell { | ||||||
|  |   font-weight: 500; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fuerzaCell { | ||||||
|  |   color: #212529; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .porcentajeCell { | ||||||
|  |   font-weight: 700; | ||||||
|  |   text-align: right; | ||||||
|  |   min-width: 80px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .seccionHeader td { | ||||||
|  |   background-color: #f8f9fa; | ||||||
|  |   font-weight: 700; | ||||||
|  |   font-size: 1.1rem; | ||||||
|  |   color: #007bff; | ||||||
|  |   border-top: 2px solid #007bff; | ||||||
|  |   border-bottom: 2px solid #007bff; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .distritoIndex { | ||||||
|  |   font-weight: 400; | ||||||
|  |   color: #6c757d; | ||||||
|  |   padding-right: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* --- INICIO DE ESTILOS PARA MÓVILES --- */ | ||||||
|  | @media (max-width: 768px) { | ||||||
|  |   .widgetContainer { | ||||||
|  |     padding: 0.5rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .resultsTable thead { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .resultsTable, | ||||||
|  |   .resultsTable tbody { | ||||||
|  |     display: block; | ||||||
|  |     width: 100%; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* 1. Cada TR es una grilla */ | ||||||
|  |   .resultsTable tr { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: 1fr auto; | ||||||
|  |     /* Columna para nombres, columna para % */ | ||||||
|  |     grid-template-rows: auto auto auto; | ||||||
|  |     /* Fila para distrito, 1ra fuerza, 2da fuerza */ | ||||||
|  |     gap: 4px 1rem; | ||||||
|  |     margin-bottom: 1rem; | ||||||
|  |     padding-bottom: 1rem; | ||||||
|  |     border-bottom: 2px solid #e0e0e0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .resultsTable tr:last-child { | ||||||
|  |     border-bottom: none; | ||||||
|  |     margin-bottom: 0; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .resultsTable td { | ||||||
|  |     padding: 0; | ||||||
|  |     border-bottom: none; | ||||||
|  |     text-align: left; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* 2. Posicionamos cada celda en la grilla */ | ||||||
|  |   .distritoCell { | ||||||
|  |     grid-column: 1 / -1; | ||||||
|  |     /* Ocupa toda la primera fila */ | ||||||
|  |     font-size: 1.1rem; | ||||||
|  |     font-weight: 700; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .fuerzaCell:nth-of-type(2) { | ||||||
|  |     grid-row: 2; | ||||||
|  |     grid-column: 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .porcentajeCell:nth-of-type(3) { | ||||||
|  |     grid-row: 2; | ||||||
|  |     grid-column: 2; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .fuerzaCell:nth-of-type(4) { | ||||||
|  |     grid-row: 3; | ||||||
|  |     grid-column: 1; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .porcentajeCell:nth-of-type(5) { | ||||||
|  |     grid-row: 3; | ||||||
|  |     grid-column: 2; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* 3. Añadimos los labels "1ra:" y "2da:" con pseudo-elementos */ | ||||||
|  |   .fuerzaCell::before { | ||||||
|  |     font-weight: 500; | ||||||
|  |     color: #6c757d; | ||||||
|  |     margin-right: 0.5rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .fuerzaCell:nth-of-type(2)::before { | ||||||
|  |     content: '1ra:'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .fuerzaCell:nth-of-type(4)::before { | ||||||
|  |     content: '2da:'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /* Ajustes de alineación */ | ||||||
|  |   .fuerzaCell { | ||||||
|  |     display: inline-flex; | ||||||
|  |     align-items: baseline; | ||||||
|  |     font-size: 0.9rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .porcentajeCell { | ||||||
|  |     font-size: 0.95rem; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   .seccionHeader td { | ||||||
|  |     display: block; | ||||||
|  |     grid-column: 1 / -1; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -0,0 +1,60 @@ | |||||||
|  | // src/features/legislativas/nacionales/TablaSeccionesWidget.tsx | ||||||
|  | import React from 'react'; | ||||||
|  | import { useQuery } from '@tanstack/react-query'; | ||||||
|  | import { getTablaSecciones } from '../../../apiService'; | ||||||
|  | import styles from './TablaResultadosWidget.module.css'; | ||||||
|  |  | ||||||
|  | export const TablaSeccionesWidget = () => { | ||||||
|  |   const ELECCION_ID = 2; | ||||||
|  |  | ||||||
|  |   const { data, isLoading, error } = useQuery({ | ||||||
|  |     queryKey: ['tablaSecciones', ELECCION_ID], | ||||||
|  |     queryFn: () => getTablaSecciones(ELECCION_ID), | ||||||
|  |     refetchInterval: 180000, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const formatPercent = (num: number) => `${num.toFixed(2)}%`; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className={styles.widgetContainer}> | ||||||
|  |       <div className={styles.header}> | ||||||
|  |         <h3>Diputados Nacionales</h3> | ||||||
|  |       </div> | ||||||
|  |       {isLoading && <p>Cargando resultados...</p>} | ||||||
|  |       {error && <p>Error al cargar los datos.</p>} | ||||||
|  |       {data && ( | ||||||
|  |         <table className={styles.resultsTable}> | ||||||
|  |           <thead> | ||||||
|  |             <tr> | ||||||
|  |               <th>Municipio</th> | ||||||
|  |               <th>1ra Fuerza</th> | ||||||
|  |               <th>%</th> | ||||||
|  |               <th>2da Fuerza</th> | ||||||
|  |               <th>%</th> | ||||||
|  |             </tr> | ||||||
|  |           </thead> | ||||||
|  |           <tbody> | ||||||
|  |             {data.map((seccion) => ( | ||||||
|  |               <React.Fragment key={seccion.seccionId}> | ||||||
|  |                 <tr className={styles.seccionHeader}> | ||||||
|  |                   <td colSpan={5}>{seccion.nombre}</td> | ||||||
|  |                 </tr> | ||||||
|  |                 {seccion.municipios.map((fila, index) => ( | ||||||
|  |                   <tr key={fila.ambitoId}> | ||||||
|  |                     <td className={styles.distritoCell}> | ||||||
|  |                       <span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre} | ||||||
|  |                     </td> | ||||||
|  |                     <td className={styles.fuerzaCell}>{fila.fuerza1Display}</td> | ||||||
|  |                     <td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td> | ||||||
|  |                     <td className={styles.fuerzaCell}>{fila.fuerza2Display}</td> | ||||||
|  |                     <td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td> | ||||||
|  |                   </tr> | ||||||
|  |                 ))} | ||||||
|  |               </React.Fragment> | ||||||
|  |             ))} | ||||||
|  |           </tbody> | ||||||
|  |         </table> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,12 @@ | |||||||
|  | // src/features/legislativas/nacionales/components/AnimatedNumber.tsx | ||||||
|  | import { useAnimatedNumber } from '../hooks/useAnimatedNumber'; | ||||||
|  |  | ||||||
|  | interface AnimatedNumberProps { | ||||||
|  |   value: number; | ||||||
|  |   formatter: (value: number) => string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const AnimatedNumber = ({ value, formatter }: AnimatedNumberProps) => { | ||||||
|  |   const animatedValue = useAnimatedNumber(value); | ||||||
|  |   return <span>{formatter(animatedValue)}</span>; | ||||||
|  | }; | ||||||