Preparación Legislativas Nacionales 2025
This commit is contained in:
		| @@ -0,0 +1,15 @@ | ||||
| // src/features/legislativas/rovinciales/DevAppLegislativas.tsx | ||||
| import { PanelNacionalWidget } from './nacionales/PanelNacionalWidget'; | ||||
| import './DevAppStyle.css' | ||||
|  | ||||
| export const DevAppLegislativas = () => { | ||||
|     return ( | ||||
|         <div className="container"> | ||||
|             <h1>Il visualizzatore di widget - Elecciones Nacionales 2025</h1> | ||||
|              | ||||
|             {/* Le pasamos el ID de la elección que queremos visualizar. | ||||
|                 Para tus datos de prueba provinciales, este ID es 1. */} | ||||
|             <PanelNacionalWidget eleccionId={2} /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,3 @@ | ||||
| .container{ | ||||
|     text-align: center; | ||||
| } | ||||
| @@ -0,0 +1,327 @@ | ||||
| /* src/features/legislativas/nacionales/PanelNaciona.css */ | ||||
| .panel-nacional-container { | ||||
|   font-family: 'Roboto', sans-serif; | ||||
|   max-width: 1200px; | ||||
|   margin: auto; | ||||
|   border: 1px solid #e0e0e0; | ||||
|   border-radius: 8px; | ||||
| } | ||||
|  | ||||
| .panel-header { | ||||
|   padding: 1rem 1.5rem; | ||||
|   border-bottom: 1px solid #e0e0e0; | ||||
| } | ||||
|  | ||||
| /* Nuevo contenedor para alinear título y selector */ | ||||
| .header-top-row { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   margin-bottom: 0.5rem; | ||||
| } | ||||
|  | ||||
| .panel-header h1 { | ||||
|   font-size: 1.5rem; | ||||
|   margin: 0; | ||||
| } | ||||
|  | ||||
| .categoria-selector { | ||||
|   min-width: 220px; | ||||
|   /* Ancho del selector */ | ||||
| } | ||||
|  | ||||
| .breadcrumbs { | ||||
|   font-size: 0.9rem; | ||||
|   color: #666; | ||||
| } | ||||
|  | ||||
| .breadcrumb-link { | ||||
|   background: none; | ||||
|   border: none; | ||||
|   color: #007bff; | ||||
|   cursor: pointer; | ||||
|   padding: 0; | ||||
| } | ||||
|  | ||||
| .breadcrumb-separator { | ||||
|   margin: 0 0.5rem; | ||||
| } | ||||
|  | ||||
| .panel-main-content { | ||||
|   display: flex; | ||||
|   height: 75vh; | ||||
|   min-height: 500px; | ||||
|   transition: all 0.5s ease-in-out; | ||||
| } | ||||
|  | ||||
| /* Columna del mapa */ | ||||
| .mapa-column { | ||||
|   flex: 2; /* Por defecto, ocupa 2/3 del espacio */ | ||||
|   position: relative; | ||||
|   transition: flex 0.5s ease-in-out; | ||||
| } | ||||
|  | ||||
| /* Columna de resultados */ | ||||
| .resultados-column { | ||||
|   flex: 1; /* Por defecto, ocupa 1/3 */ | ||||
|   overflow-y: auto; | ||||
|   padding: 1.5rem; | ||||
|   transition: all 0.5s ease-in-out; | ||||
|   min-width: 320px; /* Un ancho mínimo para que no se comprima demasiado */ | ||||
| } | ||||
|  | ||||
| /* --- NUEVOS ESTILOS --- */ | ||||
| .mapa-componente-container { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .mapa-volver-btn { | ||||
|   position: absolute; | ||||
|   top: 10px; | ||||
|   left: 10px; | ||||
|   z-index: 10; | ||||
|   padding: 8px 12px; | ||||
|   background-color: white; | ||||
|   border: 1px solid #ccc; | ||||
|   border-radius: 4px; | ||||
|   cursor: pointer; | ||||
|   box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .partido-fila  { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   margin-bottom: 1rem; | ||||
|   gap: 1rem; /* Añade un espacio entre logo, info y stats */ | ||||
| } | ||||
|  | ||||
| .partido-logo { | ||||
|   flex-shrink: 0; | ||||
|   width: 48px; | ||||
|   height: 48px; | ||||
| } | ||||
|  | ||||
| .partido-logo img { | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
|   object-fit: contain; | ||||
|   border-radius: 50%; | ||||
| } | ||||
|  | ||||
| .partido-info-wrapper { | ||||
|   flex-grow: 1; /* Permite que esta sección crezca */ | ||||
|   flex-shrink: 1; /* Permite que se encoja si es necesario */ | ||||
|   min-width: 0;   /* <-- TRUCO CLAVE DE FLEXBOX para que text-overflow funcione */ | ||||
| } | ||||
|  | ||||
| .partido-nombre { | ||||
|   font-weight: 500; | ||||
|   display: block; | ||||
|   white-space: nowrap;      /* <-- No permitir que el texto salte de línea */ | ||||
|   overflow: hidden;         /* <-- Ocultar el texto que se desborda */ | ||||
|   text-overflow: ellipsis;  /* <-- Añadir "..." al final */ | ||||
| } | ||||
|  | ||||
| .candidato-nombre { | ||||
|   font-size: 0.85rem; | ||||
|   color: #666; | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .partido-barra-background { | ||||
|   height: 15px; | ||||
|   background-color: #f0f0f0; | ||||
|   border-radius: 5px; | ||||
|   margin-top: 4px; | ||||
| } | ||||
|  | ||||
| .partido-barra-foreground { | ||||
|   height: 100%; | ||||
|   border-radius: 4px; | ||||
|   transition: width 0.5s ease-in-out; | ||||
| } | ||||
|  | ||||
| .partido-stats { | ||||
|   flex-shrink: 0; /* <-- MUY IMPORTANTE: Evita que este bloque se encoja */ | ||||
|   text-align: right; | ||||
|   min-width: 100px; /* Asegura que siempre tenga espacio suficiente */ | ||||
| } | ||||
|  | ||||
| .partido-porcentaje { | ||||
|   font-size: 1.2rem; | ||||
|   font-weight: 700; | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .partido-votos { | ||||
|   font-size: 0.8rem; | ||||
|   color: #666; | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .panel-estado-recuento { | ||||
|   margin-top: auto; | ||||
|   padding-top: 1rem; | ||||
|   border-top: 1px solid #e0e0e0; | ||||
|   display: flex; | ||||
|   justify-content: space-around; | ||||
| } | ||||
|  | ||||
| .estado-item { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .estado-item span { | ||||
|   font-size: 0.8rem; | ||||
|   color: #666; | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .estado-item strong { | ||||
|   font-size: 1.2rem; | ||||
|   color: #333; | ||||
| } | ||||
|  | ||||
| .rsm-zoomable-group { | ||||
|     transition: transform 0.75s ease-in-out; | ||||
| } | ||||
|  | ||||
| * Contenedor principal del contenido */ | ||||
| .panel-main-content { | ||||
|   display: flex; | ||||
|   height: 70vh; | ||||
|   min-height: 500px; | ||||
|   transition: all 0.5s ease-in-out; /* Transición suave para el layout */ | ||||
| } | ||||
|  | ||||
| /* Columna del mapa */ | ||||
| .mapa-column { | ||||
|   flex: 2; /* Por defecto, ocupa 2/3 del espacio */ | ||||
|   position: relative; | ||||
|   transition: flex 0.5s ease-in-out; | ||||
| } | ||||
|  | ||||
| /* Columna de resultados */ | ||||
| .resultados-column { | ||||
|   flex: 1; /* Por defecto, ocupa 1/3 */ | ||||
|   overflow-y: auto; | ||||
|   padding: 1.5rem; | ||||
|   transition: all 0.5s ease-in-out; | ||||
|   min-width: 320px; /* Un ancho mínimo para que no se comprima demasiado */ | ||||
| } | ||||
|  | ||||
| /* --- ESTADO COLAPSADO --- */ | ||||
| /* Cuando el panel principal tiene la clase 'panel-collapsed' */ | ||||
| .panel-main-content.panel-collapsed .mapa-column { | ||||
|   flex: 1 1 100%; /* El mapa ocupa todo el ancho */ | ||||
| } | ||||
|  | ||||
| .panel-main-content.panel-collapsed .resultados-column { | ||||
|   flex-basis: 0; | ||||
|   min-width: 0; | ||||
|   max-width: 0; | ||||
|   padding: 0; | ||||
|   overflow: hidden; /* Oculta el contenido para que no se desborde */ | ||||
| } | ||||
|  | ||||
| /* --- Estilo del botón para colapsar --- */ | ||||
| .panel-toggle-btn { | ||||
|   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.5rem; | ||||
|   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; | ||||
| } | ||||
|  | ||||
| .panel-toggle-btn:hover { | ||||
|   background-color: #f0f0f0; | ||||
| } | ||||
|  | ||||
| .rsm-geography { | ||||
|     cursor: pointer; | ||||
|     stroke: #000000; | ||||
|     stroke-width: 0.2px; | ||||
|     outline: none; | ||||
|     transition: filter 0.2s ease-in-out, stroke 0.2s ease-in-out, stroke-width 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| /* --- ESTADO HOVER (Sutil) --- */ | ||||
| /* Se aplica solo si la geografía NO está seleccionada */ | ||||
| .rsm-geography:not(.selected):hover { | ||||
|     filter: brightness(1.10); | ||||
|     stroke: #0000ff; | ||||
|     stroke-width: 0.5px; | ||||
| } | ||||
|  | ||||
| /* --- ESTADO SELECCIONADO (Foco) --- */ | ||||
| /* Clase que añadiremos desde React para el municipio en foco */ | ||||
| .rsm-geography.selected { | ||||
|     stroke: #0000ff; /* Borde negro para el seleccionado */ | ||||
|     stroke-width: 0.5px; /* <-- Borde más grueso para destacar */ | ||||
|     filter: none; /* Quitamos cualquier otro filtro para que se vea nítido */ | ||||
|     pointer-events: none; /* Desactivamos eventos para que no interfiera el hover */ | ||||
| } | ||||
|  | ||||
| /* Reglas para los mapas atenuados (sin cambios) */ | ||||
| .rsm-geography-faded, | ||||
| .rsm-geography-faded-municipality { | ||||
|     opacity: 0.3; | ||||
|     pointer-events: none; | ||||
| } | ||||
|  | ||||
| .rsm-geography-faded:hover, | ||||
| .rsm-geography-faded-municipality:hover { | ||||
|     filter: none; | ||||
|     stroke: #FFFFFF; | ||||
|     stroke-width: 0.5px; | ||||
| } | ||||
|  | ||||
| .partido-barra-foreground { | ||||
|   height: 100%; | ||||
|   border-radius: 4px; | ||||
|   transition: width 0.5s ease-in-out;  | ||||
| } | ||||
|  | ||||
| /* Spinner para la transición entre mapas */ | ||||
| .transition-spinner { | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|     left: 0; | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     background-color: rgba(255, 255, 255, 0.5); /* Fondo blanco semitransparente */ | ||||
|     z-index: 20; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| } | ||||
| /* Estilo del spinner en sí mismo */ | ||||
| .transition-spinner::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); } | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| // src/features/legislativas/nacionales/PanelNacionalWidget.tsx | ||||
| import { useMemo, useState, Suspense } from 'react'; | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; // <-- CAMBIO CLAVE | ||||
| import { getPanelElectoral } from '../../../apiService'; | ||||
| import { MapaNacional } from './components/MapaNacional'; | ||||
| import { PanelResultados } from './components/PanelResultados'; | ||||
| import { Breadcrumbs } from './components/Breadcrumbs'; | ||||
| import './PanelNacional.css'; | ||||
| import Select from 'react-select'; | ||||
| import type { PanelElectoralDto } from '../../../types/types'; | ||||
|  | ||||
| interface PanelNacionalWidgetProps { | ||||
|   eleccionId: number; | ||||
| } | ||||
|  | ||||
| type AmbitoState = { | ||||
|   id: string | null; | ||||
|   nivel: 'pais' | 'provincia' | 'municipio'; | ||||
|   nombre: string; | ||||
|   provinciaNombre?: string; | ||||
|   provinciaDistritoId?: string | null; | ||||
| }; | ||||
|  | ||||
| const CATEGORIAS_NACIONALES = [ | ||||
|   { value: 2, label: 'Diputados Nacionales' }, | ||||
|   { value: 1, label: 'Senadores Nacionales' }, | ||||
| ]; | ||||
|  | ||||
| // Creamos un componente interno para poder usar Suspense correctamente | ||||
| const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => { | ||||
|   // Este hook ahora suspenderá el renderizado si los datos no están listos | ||||
|   const { data } = useSuspenseQuery<PanelElectoralDto>({ | ||||
|     queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId], | ||||
|     queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId), | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <PanelResultados | ||||
|       resultados={data.resultadosPanel} | ||||
|       estadoRecuento={data.estadoRecuento} | ||||
|     /> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => { | ||||
|   const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null }); | ||||
|   const [categoriaId, setCategoriaId] = useState<number>(2); | ||||
|   const [isPanelOpen, setIsPanelOpen] = useState(true); | ||||
|  | ||||
|   const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => { | ||||
|     setAmbitoActual(prev => ({ | ||||
|       id: nuevoAmbitoId, | ||||
|       nivel: nuevoNivel, | ||||
|       nombre: nuevoNombre, | ||||
|       provinciaNombre: nuevoNivel === 'municipio' ? prev.nombre : (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 | ||||
|       }); | ||||
|     } else { | ||||
|       handleResetToPais(); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const selectedCategoria = useMemo(() => | ||||
|     CATEGORIAS_NACIONALES.find(c => c.value === categoriaId), | ||||
|     [categoriaId] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <div className="panel-nacional-container"> | ||||
|       <header className="panel-header"> | ||||
|         <div className="header-top-row"> | ||||
|           <h1>Resultados elecciones {ambitoActual.nombre}</h1> | ||||
|           <Select | ||||
|             options={CATEGORIAS_NACIONALES} | ||||
|             value={selectedCategoria} | ||||
|             onChange={(option) => option && setCategoriaId(option.value)} | ||||
|             className="categoria-selector" | ||||
|           /> | ||||
|         </div> | ||||
|         <Breadcrumbs | ||||
|           nivel={ambitoActual.nivel} | ||||
|           nombreAmbito={ambitoActual.nombre} | ||||
|           nombreProvincia={ambitoActual.provinciaNombre} | ||||
|           onReset={handleResetToPais} | ||||
|           onVolverProvincia={handleVolverAProvincia} | ||||
|         /> | ||||
|       </header> | ||||
|       <main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''}`}> | ||||
|         <div className="mapa-column"> | ||||
|           <button | ||||
|             className="panel-toggle-btn" | ||||
|             onClick={() => setIsPanelOpen(!isPanelOpen)} | ||||
|             title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"} | ||||
|           > | ||||
|             {isPanelOpen ? '›' : '‹'} | ||||
|           </button> | ||||
|           <Suspense fallback={<div className="spinner" />}> | ||||
|             <MapaNacional | ||||
|               eleccionId={eleccionId} | ||||
|               categoriaId={categoriaId} | ||||
|               nivel={ambitoActual.nivel} | ||||
|               nombreAmbito={ambitoActual.nombre} | ||||
|               provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} | ||||
|               onAmbitoSelect={handleAmbitoSelect} | ||||
|               onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} | ||||
|             /> | ||||
|           </Suspense> | ||||
|         </div> | ||||
|         <div className="resultados-column"> | ||||
|           <Suspense fallback={<div className="spinner" />}> | ||||
|             <PanelContenido | ||||
|               eleccionId={eleccionId} | ||||
|               ambitoActual={ambitoActual} | ||||
|               categoriaId={categoriaId} | ||||
|             /> | ||||
|           </Suspense> | ||||
|         </div> | ||||
|       </main> | ||||
|     </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>; | ||||
| }; | ||||
| @@ -0,0 +1,28 @@ | ||||
| // src/features/legislativas/nacionales/components/Breadcrumbs.tsx | ||||
| interface BreadcrumbsProps { | ||||
|   nivel: 'pais' | 'provincia' | 'municipio'; | ||||
|   nombreAmbito: string; | ||||
|   nombreProvincia?: string; | ||||
|   onReset: () => void; | ||||
|   onVolverProvincia: () => void; | ||||
| } | ||||
|  | ||||
| export const Breadcrumbs = ({ nivel, nombreAmbito, nombreProvincia, onReset, onVolverProvincia }: BreadcrumbsProps) => { | ||||
|   return ( | ||||
|     <div className="breadcrumbs"> | ||||
|       {nivel !== 'pais' && ( | ||||
|         <> | ||||
|           <button onClick={onReset} className="breadcrumb-link">Argentina</button> | ||||
|           <span className="breadcrumb-separator">{'>'}</span> | ||||
|         </> | ||||
|       )} | ||||
|       {nivel === 'municipio' && nombreProvincia && ( | ||||
|         <> | ||||
|           <button onClick={onVolverProvincia} className="breadcrumb-link">{nombreProvincia}</button> | ||||
|           <span className="breadcrumb-separator">{'>'}</span> | ||||
|         </> | ||||
|       )} | ||||
|       <span className="breadcrumb-actual">{nombreAmbito}</span> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,104 @@ | ||||
| // src/features/legislativas/nacionales/components/MapaNacional.tsx | ||||
| import axios from 'axios'; | ||||
| import { Suspense, useState, useEffect, useCallback } from 'react'; // <-- Asegúrate de que useCallback esté importado | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { API_BASE_URL, assetBaseUrl } from '../../../../apiService'; | ||||
| import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'; | ||||
| import { MapaProvincial } from './MapaProvincial'; | ||||
|  | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const FADED_BACKGROUND_COLOR = '#F0F0F0'; | ||||
| const normalizarTexto = (texto: string = '') => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
|  | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface MapaNacionalProps { | ||||
|   eleccionId: number; | ||||
|   categoriaId: number; | ||||
|   nivel: 'pais' | 'provincia' | 'municipio'; | ||||
|   nombreAmbito: string; | ||||
|   provinciaDistritoId: string | null; | ||||
|   onAmbitoSelect: (ambitoId: string, nivel: 'provincia' | 'municipio', nombre: string) => void; | ||||
|   onVolver: () => void; | ||||
| } | ||||
|  | ||||
| export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, provinciaDistritoId, onAmbitoSelect, onVolver }: MapaNacionalProps) => { | ||||
|   const [position, setPosition] = useState({ zoom: 1, center: [-65, -40] as PointTuple }); | ||||
|  | ||||
|   const { data: mapaDataNacional } = useSuspenseQuery<ResultadoMapaDto[]>({ | ||||
|     queryKey: ['mapaResultados', eleccionId, categoriaId, null], | ||||
|     queryFn: async () => { | ||||
|       const url = `${API_BASE_URL}/elecciones/${eleccionId}/mapa-resultados?categoriaId=${categoriaId}`; | ||||
|       const response = await axios.get(url); | ||||
|       return response.data; | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   const { data: geoDataNacional } = useSuspenseQuery<any>({ | ||||
|     queryKey: ['geoDataNacional'], | ||||
|     queryFn: () => axios.get(`${assetBaseUrl}/maps/argentina-provincias.topojson`).then(res => res.data), | ||||
|   }); | ||||
|  | ||||
|   const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d])); | ||||
|    | ||||
|   const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null; | ||||
|  | ||||
|   // El useEffect para el zoom provincial y nacional sigue siendo correcto. | ||||
|   useEffect(() => { | ||||
|     if (nivel === 'pais') { | ||||
|       setPosition({ zoom: 1, center: [-65, -40] }); | ||||
|     } else if (nivel === 'provincia') { | ||||
|       setPosition({ zoom: 7, center: [-60.5, -37] }); | ||||
|     } | ||||
|     // La lógica de centrado en municipio se delega al hijo, que llamará a `handleCalculatedCenter` | ||||
|   }, [nivel]); | ||||
|  | ||||
|   // **LA SOLUCIÓN CLAVE**: Estabilizamos la función que se pasa al hijo. | ||||
|   const handleCalculatedCenter = useCallback((center: PointTuple, zoom: number) => { | ||||
|     setPosition({ center, zoom }); | ||||
|   }, []); // El array de dependencias vacío asegura que la función nunca cambie | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-componente-container"> | ||||
|       {nivel !== 'pais' && <button onClick={onVolver} className="mapa-volver-btn">← Volver</button>} | ||||
|       <ComposableMap projection="geoMercator" projectionConfig={{ scale: 700, center: [-65, -40] }} style={{ width: "100%", height: "100%" }}> | ||||
|         <ZoomableGroup center={position.center} zoom={position.zoom} filterZoomEvent={() => false}> | ||||
|           <Geographies geography={geoDataNacional}> | ||||
|             {({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => { | ||||
|               const resultado = resultadosNacionalesPorNombre.get(normalizarTexto(geo.properties.nombre)); | ||||
|               const esProvinciaActiva = provinciaDistritoId && resultado?.ambitoId === provinciaDistritoId; | ||||
|  | ||||
|               return ( | ||||
|                 <Geography | ||||
|                   key={geo.rsmKey} geography={geo} | ||||
|                   className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`} | ||||
|                   style={{ visibility: esProvinciaActiva ? 'hidden' : 'visible' }} | ||||
|                   fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR} | ||||
|                   onClick={() => resultado && onAmbitoSelect(resultado.ambitoId, 'provincia', resultado.ambitoNombre)} | ||||
|                 /> | ||||
|               ); | ||||
|             })} | ||||
|           </Geographies> | ||||
|  | ||||
|           {provinciaDistritoId && ( | ||||
|             <Suspense fallback={null}> | ||||
|               <MapaProvincial | ||||
|                 eleccionId={eleccionId} | ||||
|                 categoriaId={categoriaId} | ||||
|                 distritoId={provinciaDistritoId} | ||||
|                 nombreProvincia={"BUENOS AIRES"} // Esto se podría hacer dinámico si fuera necesario | ||||
|                 nombreMunicipioSeleccionado={nombreMunicipioSeleccionado} | ||||
|                 onMunicipioSelect={(ambitoId, nombre) => onAmbitoSelect(ambitoId, 'municipio', nombre)} | ||||
|                 onCalculatedCenter={handleCalculatedCenter} // Pasamos la función estabilizada | ||||
|                 nivel={nivel as 'provincia' | 'municipio'} // El cast de tipo sigue siendo necesario y correcto | ||||
|               /> | ||||
|             </Suspense> | ||||
|           )} | ||||
|         </ZoomableGroup> | ||||
|       </ComposableMap> | ||||
|       <Tooltip id="mapa-tooltip" /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,82 @@ | ||||
| // src/features/legislativas/nacionales/components/MapaProvincial.tsx | ||||
| import axios from 'axios'; | ||||
| import { useEffect } from 'react'; | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { Geographies, Geography } from 'react-simple-maps'; | ||||
| import { geoCentroid } from 'd3-geo'; | ||||
| import { feature } from 'topojson-client'; | ||||
| import { API_BASE_URL, assetBaseUrl } from '../../../../apiService'; | ||||
| import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'; | ||||
|  | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const normalizarTexto = (texto: string = ''): string => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface MapaProvincialProps { | ||||
|   eleccionId: number; | ||||
|   categoriaId: number; | ||||
|   distritoId: string; | ||||
|   nombreProvincia: string; | ||||
|   nombreMunicipioSeleccionado: string | null; | ||||
|   nivel: 'provincia' | 'municipio'; | ||||
|   onMunicipioSelect: (ambitoId: string, nombre: string) => void; | ||||
|   onCalculatedCenter: (center: PointTuple, zoom: number) => void; | ||||
| } | ||||
|  | ||||
| export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProvincia, nombreMunicipioSeleccionado, nivel, onMunicipioSelect, onCalculatedCenter }: MapaProvincialProps) => { | ||||
|   const { data: mapaData = [] } = useSuspenseQuery<ResultadoMapaDto[]>({ | ||||
|     queryKey: ['mapaResultados', eleccionId, categoriaId, distritoId], | ||||
|     queryFn: async () => { | ||||
|       const url = `${API_BASE_URL}/elecciones/${eleccionId}/mapa-resultados?categoriaId=${categoriaId}&distritoId=${distritoId}`; | ||||
|       const response = await axios.get(url); | ||||
|       return response.data; | ||||
|     }, | ||||
|   }); | ||||
|    | ||||
|   // El nombre del archivo ahora es completamente dinámico | ||||
|   const { data: geoData } = useSuspenseQuery<any>({ | ||||
|     queryKey: ['geoDataProvincial', nombreProvincia], | ||||
|     queryFn: async () => { | ||||
|       const nombreNormalizado = nombreProvincia.toLowerCase().replace(/ /g, '_'); | ||||
|       const mapFile = `departamentos-${nombreNormalizado}.topojson`; | ||||
|       return axios.get(`${assetBaseUrl}/maps/${mapFile}`).then(res => res.data); | ||||
|     }, | ||||
|   }); | ||||
|  | ||||
|   // useEffect para calcular y "exportar" la posición del municipio al padre | ||||
|   useEffect(() => { | ||||
|     if (nivel === 'municipio' && geoData?.objects && nombreMunicipioSeleccionado) { | ||||
|       const geometries = geoData.objects[Object.keys(geoData.objects)[0]].geometries; | ||||
|       const municipioGeo = geometries.find((g: any) => normalizarTexto(g.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado)); | ||||
|       if (municipioGeo) { | ||||
|           const municipioFeature = feature(geoData, municipioGeo); | ||||
|           const centroid = geoCentroid(municipioFeature); | ||||
|           // Usamos un zoom genérico alto para cualquier municipio | ||||
|           onCalculatedCenter(centroid as PointTuple, 40); | ||||
|       } | ||||
|     } | ||||
|   }, [nivel, nombreMunicipioSeleccionado, geoData, onCalculatedCenter]); | ||||
|  | ||||
|   const resultadosPorNombre = new Map<string, ResultadoMapaDto>(mapaData.map(d => [normalizarTexto(d.ambitoNombre), d])); | ||||
|  | ||||
|   return ( | ||||
|     <Geographies geography={geoData}> | ||||
|       {({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => { | ||||
|         const resultado = resultadosPorNombre.get(normalizarTexto(geo.properties.departamento)); | ||||
|         const esSeleccionado = nombreMunicipioSeleccionado ? normalizarTexto(geo.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado) : false; | ||||
|  | ||||
|         return ( | ||||
|           <Geography | ||||
|             key={geo.rsmKey} | ||||
|             geography={geo} | ||||
|             className={`rsm-geography ${esSeleccionado ? 'selected' : ''} ${nombreMunicipioSeleccionado && !esSeleccionado ? 'rsm-geography-faded-municipality' : ''}`} | ||||
|             fill={resultado?.colorGanador || DEFAULT_MAP_COLOR} | ||||
|             onClick={resultado ? () => onMunicipioSelect(resultado.ambitoId.toString(), resultado.ambitoNombre) : undefined} | ||||
|             data-tooltip-id="mapa-tooltip" | ||||
|             data-tooltip-content={geo.properties.departamento} | ||||
|           /> | ||||
|         ); | ||||
|       })} | ||||
|     </Geographies> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,55 @@ | ||||
| // src/features/legislativas/nacionales/components/PanelResultados.tsx | ||||
| import type { ResultadoTicker, EstadoRecuentoTicker } from '../../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../../components/common/ImageWithFallback'; | ||||
| import { assetBaseUrl } from '../../../../apiService'; | ||||
| import { AnimatedNumber } from './AnimatedNumber'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR'); | ||||
|  | ||||
| interface PanelResultadosProps { | ||||
|   resultados: ResultadoTicker[]; | ||||
|   estadoRecuento: EstadoRecuentoTicker; | ||||
| } | ||||
|  | ||||
| export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosProps) => { | ||||
|   return ( | ||||
|     <div className="panel-resultados"> | ||||
|       <div className="panel-partidos-container"> | ||||
|         {resultados.map(partido => ( | ||||
|           <div key={partido.id} className="partido-fila"> | ||||
|             <div className="partido-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> | ||||
|             </div> | ||||
|             <div className="partido-info-wrapper"> | ||||
|               <span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span> | ||||
|               {partido.nombreCandidato && <span className="candidato-nombre">{partido.nombreCandidato}</span>} | ||||
|               <div className="partido-barra-background"> | ||||
|                 <div className="partido-barra-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }} /> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="partido-stats"> | ||||
|               <span className="partido-porcentaje"> | ||||
|                 <AnimatedNumber value={partido.porcentaje} formatter={formatPercent} /> | ||||
|               </span> | ||||
|               <span className="partido-votos"> | ||||
|                 <AnimatedNumber value={partido.votos} formatter={formatVotes} /> votos | ||||
|               </span> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|  | ||||
|       <div className="panel-estado-recuento"> | ||||
|         <div className="estado-item"> | ||||
|           <span>Participación</span> | ||||
|           <strong><AnimatedNumber value={estadoRecuento.participacionPorcentaje} formatter={formatPercent} /></strong> | ||||
|         </div> | ||||
|         <div className="estado-item"> | ||||
|           <span>Mesas Escrutadas</span> | ||||
|           <strong><AnimatedNumber value={estadoRecuento.mesasTotalizadasPorcentaje} formatter={formatPercent} /></strong> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,44 @@ | ||||
| // src/features/legislativas/nacionales/components/hooks/useAnimatedNumber.ts | ||||
| import { useState, useEffect, useRef } from 'react'; | ||||
|  | ||||
| const easeOutQuad = (t: number) => t * (2 - t); | ||||
|  | ||||
| export const useAnimatedNumber = ( | ||||
|   endValue: number, | ||||
|   duration: number = 700 // Duración de la animación en milisegundos | ||||
| ) => { | ||||
|   const [currentValue, setCurrentValue] = useState(endValue); | ||||
|   const previousValueRef = useRef(endValue); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const startValue = previousValueRef.current; | ||||
|     let animationFrameId: number; | ||||
|     const startTime = Date.now(); | ||||
|  | ||||
|     const animate = () => { | ||||
|       const elapsedTime = Date.now() - startTime; | ||||
|       const progress = Math.min(elapsedTime / duration, 1); | ||||
|       const easedProgress = easeOutQuad(progress); | ||||
|  | ||||
|       const newAnimatedValue = startValue + (endValue - startValue) * easedProgress; | ||||
|       setCurrentValue(newAnimatedValue); | ||||
|  | ||||
|       if (progress < 1) { | ||||
|         animationFrameId = requestAnimationFrame(animate); | ||||
|       } else { | ||||
|         // Asegurarse de que el valor final sea exacto | ||||
|         setCurrentValue(endValue); | ||||
|         previousValueRef.current = endValue; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     animationFrameId = requestAnimationFrame(animate); | ||||
|  | ||||
|     return () => { | ||||
|       cancelAnimationFrame(animationFrameId); | ||||
|       previousValueRef.current = endValue; | ||||
|     }; | ||||
|   }, [endValue, duration]); | ||||
|  | ||||
|   return currentValue; | ||||
| }; | ||||
| @@ -0,0 +1,168 @@ | ||||
| /* src/features/legislativas/rovinciales/BancasWidget.css | ||||
|  | ||||
| /* Contenedor principal del widget */ | ||||
| .bancas-widget-container { | ||||
|     font-family: 'Roboto', sans-serif; | ||||
|     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; | ||||
| } | ||||
|  | ||||
| /* Cabecera */ | ||||
| .bancas-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 12px; | ||||
| } | ||||
|  | ||||
| .bancas-header h4 { | ||||
|     margin: 0; | ||||
|     font-size: 1.1rem; | ||||
|     font-weight: 700; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| /* Pestañas de Cámara */ | ||||
| .chamber-tabs-bancas { | ||||
|     display: flex; | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|  | ||||
| .chamber-tabs-bancas button { | ||||
|     flex: 1; | ||||
|     padding: 10px; | ||||
|     font-size: 0.95rem; | ||||
|     font-weight: 500; | ||||
|     text-align: center; | ||||
|     border: none; | ||||
|     background-color: #e9ecef; | ||||
|     color: #495057; | ||||
|     cursor: pointer; | ||||
|     transition: all 0.2s ease-in-out; | ||||
|     border-bottom: 3px solid transparent; | ||||
| } | ||||
|  | ||||
| .chamber-tabs-bancas button:disabled { | ||||
|     color: #adb5bd; | ||||
|     background-color: #f8f9fa; | ||||
|     cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .chamber-tabs-bancas button:not(:disabled):hover { | ||||
|     background-color: #dee2e6; | ||||
| } | ||||
|  | ||||
| .chamber-tabs-bancas button.active { | ||||
|     background-color: #fff; | ||||
|     color: #007bff; | ||||
|     font-weight: 700; | ||||
|     border-bottom: 3px solid #007bff; | ||||
| } | ||||
|  | ||||
| /* Contenedor del contenido principal */ | ||||
| .bancas-content-container { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     gap: 24px; | ||||
|     /* Un poco más de espacio */ | ||||
|     background-color: #fff; | ||||
|     padding: 16px; | ||||
|     border-radius: 6px; | ||||
| } | ||||
|  | ||||
| /* Contenedor del gráfico de Waffle */ | ||||
| .waffle-chart-container { | ||||
|     flex: 3; | ||||
|     min-width: 250px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| } | ||||
|  | ||||
| /* --- NUEVOS ESTILOS PARA AGRUPAR --- */ | ||||
| .waffle-grid-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     /* Apila los bloques de partido verticalmente */ | ||||
|     gap: 12px; | ||||
|     /* Espacio entre los bloques de cada partido */ | ||||
|     width: 100%; | ||||
| } | ||||
|  | ||||
| .partido-bloque { | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     gap: 4px; | ||||
|     /* Espacio entre las celdas de un mismo partido */ | ||||
| } | ||||
|  | ||||
| /* Celdas individuales del Waffle (Bancas) */ | ||||
| .waffle-cell { | ||||
|     width: 20px; | ||||
|     /* Tamaño fijo para las celdas */ | ||||
|     height: 20px; | ||||
|     border-radius: 4px; | ||||
|     border: 1px solid rgba(0, 0, 0, 0.15); | ||||
|     box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.2); | ||||
| } | ||||
|  | ||||
| /* Leyenda de partidos */ | ||||
| .leyenda-container { | ||||
|     flex: 2; | ||||
|     min-width: 200px; | ||||
|     border-left: 1px solid #e9ecef; | ||||
|     padding-left: 20px; | ||||
| } | ||||
|  | ||||
| /* Estilos de la lista de partidos */ | ||||
| .partido-lista-bancas { | ||||
|     list-style: none; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
| } | ||||
|  | ||||
| .partido-lista-bancas li { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     margin-bottom: 10px; | ||||
|     font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .partido-color-box { | ||||
|     width: 16px; | ||||
|     height: 16px; | ||||
|     border-radius: 3px; | ||||
|     margin-right: 8px; | ||||
|     flex-shrink: 0; | ||||
|     border: 1px solid rgba(0, 0, 0, 0.1); | ||||
| } | ||||
|  | ||||
| .partido-nombre { | ||||
|     flex-grow: 1; | ||||
|     margin-right: 8px; | ||||
|     color: #495057; | ||||
| } | ||||
|  | ||||
| .partido-bancas { | ||||
|     font-weight: 700; | ||||
|     font-size: 1rem; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| /* Mensajes de carga y error */ | ||||
| .loading-text, | ||||
| .error-text { | ||||
|     width: 100%; | ||||
|     text-align: center; | ||||
|     color: #6c757d; | ||||
|     padding: 20px; | ||||
|     font-style: italic; | ||||
| } | ||||
| @@ -0,0 +1,198 @@ | ||||
| // src/features/legislativas/provinciales/BancasWidget.tsx (Corregido) | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; // --- CAMBIO: Importar react-select --- | ||||
| import { getBancasPorSeccion, getSeccionesElectoralesConCargos } from '../../../apiService'; | ||||
| import type { ProyeccionBancas, MunicipioSimple } from '../../../types/types'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import './BancasWidget.css'; | ||||
| import type { Property } from 'csstype'; | ||||
|  | ||||
| type CamaraType = 'diputados' | 'senadores'; | ||||
|  | ||||
| // --- CAMBIO: Estilos para el nuevo selector --- | ||||
| const customSelectStyles = { | ||||
|     control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }), | ||||
|     menu: (base: any) => ({ ...base, zIndex: 10 }), | ||||
| }; | ||||
|  | ||||
| const WaffleDisplay = ({ data }: { data: ProyeccionBancas['proyeccion'] }) => { | ||||
|     // El componente WaffleDisplay no necesita cambios en su lógica | ||||
|     return ( | ||||
|         <div className="waffle-grid-container"> | ||||
|             {data.map(partido => ( | ||||
|                 partido.bancas > 0 && ( | ||||
|                     <div | ||||
|                         key={partido.agrupacionId} | ||||
|                         className="partido-bloque" | ||||
|                         data-tooltip-id="banca-tooltip" | ||||
|                         data-tooltip-content={`${partido.nombreCorto || partido.agrupacionNombre}: ${partido.bancas} bancas`} | ||||
|                     > | ||||
|                         {Array.from({ length: partido.bancas }).map((_, index) => ( | ||||
|                             <div | ||||
|                                 key={index} | ||||
|                                 className="waffle-cell" | ||||
|                                 style={{ backgroundColor: partido.color as Property.BackgroundColor || '#cccccc' }} | ||||
|                             /> | ||||
|                         ))} | ||||
|                     </div> | ||||
|                 ) | ||||
|             ))} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const BancasWidget = () => { | ||||
|     const [secciones, setSecciones] = useState<MunicipioSimple[]>([]); | ||||
|     // --- CAMBIO: Adaptar el estado para react-select --- | ||||
|     const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|     const [camaraActiva, setCamaraActiva] = useState<CamaraType>('diputados'); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchSecciones = async () => { | ||||
|             try { | ||||
|                 const seccionesData = await getSeccionesElectoralesConCargos(); | ||||
|                 if (seccionesData && seccionesData.length > 0) { | ||||
|                     // --- LÓGICA DE ORDENAMIENTO --- | ||||
|                     const orden = new Map([ | ||||
|                         ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|                         ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|                     ]); | ||||
|  | ||||
|                     const getOrden = (nombre: string) => { | ||||
|                         const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|                         return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|                     }; | ||||
|                      | ||||
|                     // Ordenamos el array de datos ANTES de guardarlo en el estado | ||||
|                     seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|  | ||||
|                     setSecciones(seccionesData); | ||||
|  | ||||
|                     if (!selectedSeccion) { | ||||
|                         setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre }); | ||||
|                     } | ||||
|                 } | ||||
|             } catch (err) { | ||||
|                 console.error("Error cargando secciones electorales:", err); | ||||
|             } | ||||
|         }; | ||||
|         fetchSecciones(); | ||||
|     }, [selectedSeccion]); | ||||
|  | ||||
|     // --- CAMBIO: Formatear opciones para react-select --- | ||||
|     const seccionOptions = useMemo(() => | ||||
|         secciones.map(s => ({ value: s.id, label: s.nombre })), | ||||
|         [secciones]); | ||||
|  | ||||
|     const seccionSeleccionada = useMemo(() => | ||||
|         secciones.find(s => s.id === selectedSeccion?.value), | ||||
|         [secciones, selectedSeccion]); | ||||
|  | ||||
|     const camarasDisponibles = useMemo(() => | ||||
|         seccionSeleccionada?.camarasDisponibles || [], | ||||
|         [seccionSeleccionada]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (seccionSeleccionada && camarasDisponibles.length > 0) { | ||||
|             if (!camarasDisponibles.includes(camaraActiva)) { | ||||
|                 setCamaraActiva(camarasDisponibles[0]); | ||||
|             } | ||||
|         } | ||||
|     }, [seccionSeleccionada, camarasDisponibles, camaraActiva]); | ||||
|  | ||||
|     const { | ||||
|         data, | ||||
|         isLoading, | ||||
|         error | ||||
|     } = useQuery<ProyeccionBancas, Error>({ | ||||
|         queryKey: ['bancasPorSeccion', selectedSeccion?.value, camaraActiva], | ||||
|         queryFn: () => getBancasPorSeccion(selectedSeccion!.value, camaraActiva), | ||||
|         enabled: !!selectedSeccion && camarasDisponibles.includes(camaraActiva), | ||||
|         retry: (failureCount, error: any) => { | ||||
|             if (error.response?.status === 404) return false; | ||||
|             return failureCount < 3; | ||||
|         }, | ||||
|     }); | ||||
|  | ||||
|     const getErrorMessage = () => { | ||||
|         if (error) { | ||||
|             if ((error as any).response?.status === 404) { | ||||
|                 return `La proyección para ${camaraActiva} en esta sección aún no está disponible.`; | ||||
|             } | ||||
|             return "No se pudo conectar para obtener los datos."; | ||||
|         } | ||||
|         return null; | ||||
|     }; | ||||
|     const errorMessage = getErrorMessage(); | ||||
|  | ||||
|     // --- CAMBIO: Ordenar la leyenda (y por lo tanto el gráfico) de más a menos bancas --- | ||||
|     const leyendaData = useMemo(() => | ||||
|         data?.proyeccion | ||||
|             .filter(p => p.bancas > 0) | ||||
|             .sort((a, b) => b.bancas - a.bancas) // Ordena de mayor a menor | ||||
|         || [], | ||||
|         [data]); | ||||
|  | ||||
|     const totalBancasEnJuego = useMemo(() => | ||||
|         data?.proyeccion.reduce((sum, p) => sum + p.bancas, 0) || 0, | ||||
|         [data]); | ||||
|  | ||||
|     return ( | ||||
|         <div className="bancas-widget-container"> | ||||
|             <div className="bancas-header"> | ||||
|                 <h4>Bancas Proyectadas: {totalBancasEnJuego}</h4> | ||||
|                 <Select | ||||
|                     options={seccionOptions} | ||||
|                     value={selectedSeccion} | ||||
|                     onChange={(option) => setSelectedSeccion(option)} | ||||
|                     isLoading={secciones.length === 0} | ||||
|                     placeholder="Seleccionar..." | ||||
|                     styles={customSelectStyles} | ||||
|                     isSearchable={false} | ||||
|                 /> | ||||
|             </div> | ||||
|  | ||||
|             <div className="chamber-tabs-bancas"> | ||||
|                 <button | ||||
|                     className={camaraActiva === 'diputados' ? 'active' : ''} | ||||
|                     onClick={() => setCamaraActiva('diputados')} | ||||
|                     disabled={seccionSeleccionada && !camarasDisponibles.includes('diputados')} | ||||
|                 > | ||||
|                     Diputados | ||||
|                 </button> | ||||
|                 <button | ||||
|                     className={camaraActiva === 'senadores' ? 'active' : ''} | ||||
|                     onClick={() => setCamaraActiva('senadores')} | ||||
|                     disabled={seccionSeleccionada && !camarasDisponibles.includes('senadores')} | ||||
|                 > | ||||
|                     Senadores | ||||
|                 </button> | ||||
|             </div> | ||||
|  | ||||
|             <div className="bancas-content-container"> | ||||
|                 <div className="waffle-chart-container"> | ||||
|                     {isLoading ? <p className="loading-text">Cargando...</p> : | ||||
|                         errorMessage ? <p className="error-text">{errorMessage}</p> : | ||||
|                             totalBancasEnJuego > 0 ? <WaffleDisplay data={leyendaData} /> : | ||||
|                                 <p>No hay bancas proyectadas para mostrar.</p> | ||||
|                     } | ||||
|                 </div> | ||||
|                 <div className="leyenda-container"> | ||||
|                     <ul className="partido-lista-bancas"> | ||||
|                         {leyendaData.map(partido => ( | ||||
|                             <li key={partido.agrupacionId}> | ||||
|                                 <span className="partido-color-box" style={{ backgroundColor: partido.color as Property.BackgroundColor || '#cccccc' }}></span> | ||||
|                                 <span className="partido-nombre"> | ||||
|                                     {partido.nombreCorto || partido.agrupacionNombre} | ||||
|                                 </span> | ||||
|                                 <strong className="partido-bancas">{partido.bancas}</strong> | ||||
|                             </li> | ||||
|                         ))} | ||||
|                     </ul> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <Tooltip id="banca-tooltip" /> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,124 @@ | ||||
| // src/features/legislativas/provinciales/ConcejalesPorSeccionWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; // Reutilizamos los estilos del ticker | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| // Estilos personalizados para que el selector se vea bien | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), // Para que el menú se superponga | ||||
| }; | ||||
|  | ||||
| const CATEGORIA_ID = 7; // ID para Concejales | ||||
|  | ||||
| export const ConcejalesPorSeccionWidget = () => { | ||||
|   const [secciones, setSecciones] = useState<MunicipioSimple[]>([]); | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // useEffect para obtener la lista de secciones una sola vez | ||||
|   useEffect(() => { | ||||
|     getSeccionesElectorales().then(seccionesData => { | ||||
|       if (seccionesData && seccionesData.length > 0) { | ||||
|         const orden = new Map([ | ||||
|           ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|           ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|         ]); | ||||
|         const getOrden = (nombre: string) => { | ||||
|           const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|           return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|         }; | ||||
|         seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|          | ||||
|         setSecciones(seccionesData); | ||||
|         // Establecemos la primera sección de la lista ordenada como la por defecto | ||||
|         if (!selectedSeccion) { | ||||
|             setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre }); | ||||
|         } | ||||
|       } | ||||
|     }); | ||||
|   }, [selectedSeccion]); // Dependencia para asegurar que no se resetee la selección del usuario | ||||
|  | ||||
|   // Transformamos los datos para react-select | ||||
|   const seccionOptions = useMemo(() => | ||||
|     secciones.map(s => ({ value: s.id, label: s.nombre })), | ||||
|   [secciones]); | ||||
|  | ||||
|   // Query para obtener los resultados de la sección seleccionada | ||||
|   const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({ | ||||
|     queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedSeccion, | ||||
|   }); | ||||
|  | ||||
|   const resultados = data?.resultados || []; | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = resultados; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-concejales-${selectedSeccion?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>CONCEJALES POR SECCIÓN</h3> | ||||
|         <Select | ||||
|           options={seccionOptions} | ||||
|           value={selectedSeccion} | ||||
|           onChange={(option) => setSelectedSeccion(option)} | ||||
|           isLoading={secciones.length === 0} | ||||
|           placeholder="Seleccionar sección..." | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>} | ||||
|         {!selectedSeccion && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,84 @@ | ||||
| // src/features/legislativas/provinciales/ConcejalesTickerWidget.tsx | ||||
| import { useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { CategoriaResumen, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; // Reutilizamos los mismos estilos | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const CATEGORIA_ID = 7; // ID para Concejales | ||||
|  | ||||
| export const ConcejalesTickerWidget = () => { | ||||
|   const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ | ||||
|     queryKey: ['resumenProvincial'], | ||||
|     queryFn: getResumenProvincial, | ||||
|     refetchInterval: 30000, | ||||
|   }); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // Usamos useMemo para encontrar los datos específicos de Concejales | ||||
|   const ConcejalesData = useMemo(() => { | ||||
|     return categorias?.find(c => c.categoriaId === CATEGORIA_ID); | ||||
|   }, [categorias]); | ||||
|  | ||||
|   if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>; | ||||
|   if (error || !ConcejalesData) return <div className="ticker-card error"><p>Datos de Concejales no disponibles.</p></div>; | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = ConcejalesData.resultados; | ||||
|   if (ConcejalesData.resultados.length > cantidadAMostrar) { | ||||
|     const topParties = ConcejalesData.resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = ConcejalesData.resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-Concejales`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else { | ||||
|     displayResults = ConcejalesData.resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>RESUMEN DE {ConcejalesData.categoriaNombre}</h3> | ||||
|         <div className="ticker-stats"> | ||||
|           <span>Mesas: <strong>{formatPercent(ConcejalesData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span> | ||||
|           <span>Part: <strong>{formatPercent(ConcejalesData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,119 @@ | ||||
| // src/features/legislativas/provinciales/ConcejalesWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), | ||||
| }; | ||||
|  | ||||
| const CATEGORIA_ID = 7; // ID para Concejales | ||||
|  | ||||
| export const ConcejalesWidget = () => { | ||||
|   const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   // 1. Query para la configuración (se había eliminado por error) | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   // 2. Query para la lista de municipios | ||||
|   const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({ | ||||
|     // Usamos una clave genérica porque siempre pedimos la lista completa. | ||||
|     queryKey: ['municipios'], | ||||
|     // Llamamos a la función sin argumentos para obtener todos los municipios. | ||||
|     queryFn: () => getMunicipios(), | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (municipios.length > 0 && !selectedMunicipio) { | ||||
|       const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'LA PLATA'); | ||||
|       if (laPlata) { | ||||
|         setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre }); | ||||
|       } | ||||
|     } | ||||
|   }, [municipios, selectedMunicipio]); | ||||
|  | ||||
|   const municipioOptions = useMemo(() => | ||||
|     municipios | ||||
|       .map(m => ({ value: m.id, label: m.nombre })) | ||||
|       .sort((a, b) => a.label.localeCompare(b.label)), | ||||
|     [municipios]); | ||||
|  | ||||
|   const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({ | ||||
|     queryKey: ['resultadosPorMunicipio', selectedMunicipio?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedMunicipio, | ||||
|   }); | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = resultados || []; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-concejales-${selectedMunicipio?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card" style={{ gridColumn: '1 / -1' }}> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>CONCEJALES POR MUNICIPIO</h3> | ||||
|         <Select | ||||
|           options={municipioOptions} | ||||
|           value={selectedMunicipio} | ||||
|           onChange={(option) => setSelectedMunicipio(option)} | ||||
|           isLoading={isLoadingMunicipios} | ||||
|           placeholder="Buscar y seleccionar un municipio..." | ||||
|           isClearable | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>} | ||||
|         {!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|               <div className="party-candidate-name"> | ||||
|                 {partido.nombreCandidato} | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,209 @@ | ||||
| /* src/features/legislativas/provinciales/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 */ | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| // src/features/legislativas/provinciales/CongresoWidget.tsx | ||||
| import { useState, useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { ParliamentLayout } from '../../../components/common/ParliamentLayout'; | ||||
| import { SenateLayout } from '../../../components/common/SenateLayout'; | ||||
| import { getComposicionCongreso, getBancadasDetalle } from '../../../apiService'; | ||||
| import type { ComposicionData, BancadaDetalle } from '../../../apiService'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import './CongresoWidget.css'; | ||||
|  | ||||
| type CamaraType = 'diputados' | 'senadores'; | ||||
| const DEFAULT_COLOR = '#808080'; | ||||
|  | ||||
| interface CongresoWidgetProps { | ||||
|   eleccionId: number; | ||||
| } | ||||
|  | ||||
| export const CongresoWidget = ({ eleccionId }: CongresoWidgetProps) => { | ||||
|   const [camaraActiva, setCamaraActiva] = useState<CamaraType>('diputados'); | ||||
|  | ||||
|   const { data: composicionData, isLoading: isLoadingComposicion, error: errorComposicion } = useQuery<ComposicionData>({ | ||||
|     queryKey: ['composicionCongreso', eleccionId], | ||||
|     queryFn: () => getComposicionCongreso(eleccionId), | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   const { data: bancadasDetalle = [] } = useQuery<BancadaDetalle[]>({ | ||||
|     queryKey: ['bancadasDetalle', eleccionId], | ||||
|     queryFn: () => getBancadasDetalle(eleccionId), | ||||
|     enabled: !!composicionData, | ||||
|   }); | ||||
|  | ||||
|   const datosCamaraActual = composicionData ? composicionData[camaraActiva] : null; | ||||
|  | ||||
|   const esModoOficial = bancadasDetalle.length > 0; | ||||
|  | ||||
|   // --- LÓGICA DE SEATFILLDATA --- | ||||
|   const seatFillData = useMemo(() => { | ||||
|     if (!datosCamaraActual) return []; | ||||
|  | ||||
|     if (esModoOficial) { | ||||
|       // --- MODO OFICIAL --- | ||||
|       const camaraId = camaraActiva === 'diputados' ? 0 : 1; | ||||
|       const bancadasDeCamara = bancadasDetalle.filter(b => b.camara === camaraId); | ||||
|       const colorMap = new Map<string, string>(); | ||||
|       datosCamaraActual.partidos.forEach(p => { if (p.id && p.color) colorMap.set(p.id, p.color); }); | ||||
|  | ||||
|       // 1. Creamos un array del tamaño correcto, lleno de 'null's | ||||
|       const size = camaraActiva === 'diputados' ? 92 : 46; | ||||
|       const finalSeatData = new Array(size).fill(null); | ||||
|  | ||||
|       // 2. Poblamos el array usando NumeroBanca como índice | ||||
|       bancadasDeCamara.forEach(bancada => { | ||||
|         // El índice del SVG es NumeroBanca - 1 | ||||
|         const index = bancada.numeroBanca - 1; | ||||
|         if (index >= 0 && index < size) { | ||||
|           finalSeatData[index] = { | ||||
|             color: bancada.agrupacionPoliticaId ? colorMap.get(bancada.agrupacionPoliticaId) || DEFAULT_COLOR : DEFAULT_COLOR, | ||||
|             ocupante: bancada.ocupante | ||||
|           }; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       return finalSeatData; | ||||
|  | ||||
|     } else { | ||||
|       // --- MODO PROYECCIÓN --- | ||||
|       return datosCamaraActual.partidos.flatMap(party => { | ||||
|         const seatColor = party.color || DEFAULT_COLOR; | ||||
|         return Array(party.bancasTotales).fill({ color: seatColor, ocupante: null }); | ||||
|       }); | ||||
|     } | ||||
|   }, [datosCamaraActual, bancadasDetalle, camaraActiva]); | ||||
|  | ||||
|   if (isLoadingComposicion) return <div className="congreso-container loading">Cargando...</div>; | ||||
|   if (errorComposicion || !datosCamaraActual) return <div className="congreso-container error">No se pudo cargar la composición.</div>; | ||||
|  | ||||
|   const partidosOrdenados = datosCamaraActual.partidos; | ||||
|  | ||||
|   return ( | ||||
|     <div className="congreso-container"> | ||||
|       <div className="congreso-grafico"> | ||||
|         {camaraActiva === 'diputados' ? ( | ||||
|           <ParliamentLayout | ||||
|             seatData={seatFillData} | ||||
|             // Solo pasamos la prop 'presidenteBancada' si NO estamos en modo oficial | ||||
|             presidenteBancada={!esModoOficial ? datosCamaraActual.presidenteBancada : undefined} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <SenateLayout | ||||
|             seatData={seatFillData} | ||||
|             presidenteBancada={!esModoOficial ? datosCamaraActual.presidenteBancada : undefined} | ||||
|           /> | ||||
|         )} | ||||
|       </div> | ||||
|  | ||||
|       <div className="congreso-summary"> | ||||
|         <div className="chamber-tabs"> | ||||
|           <button | ||||
|             className={camaraActiva === 'diputados' ? 'active' : ''} | ||||
|             onClick={() => setCamaraActiva('diputados')} | ||||
|           > | ||||
|             Diputados | ||||
|           </button> | ||||
|           <button | ||||
|             className={camaraActiva === 'senadores' ? 'active' : ''} | ||||
|             onClick={() => setCamaraActiva('senadores')} | ||||
|           > | ||||
|             Senadores | ||||
|           </button> | ||||
|         </div> | ||||
|  | ||||
|         <h3>{datosCamaraActual.camaraNombre}</h3> | ||||
|         <div className="summary-metric"> | ||||
|           <span>Total de Bancas</span> | ||||
|           <strong>{datosCamaraActual.totalBancas}</strong> | ||||
|         </div> | ||||
|         <hr /> | ||||
|         <ul className="partido-lista"> | ||||
|           {partidosOrdenados.map(partido => ( | ||||
|             <li key={partido.id}> | ||||
|               <span className="partido-color-box" style={{ backgroundColor: partido.color || DEFAULT_COLOR }}></span> | ||||
|               <span className="partido-nombre"> | ||||
|                 {partido.nombreCorto || partido.nombre} | ||||
|               </span> | ||||
|               <strong className="partido-bancas">{partido.bancasTotales}</strong> | ||||
|             </li> | ||||
|           ))} | ||||
|         </ul> | ||||
|       </div> | ||||
|       {/* Es importante que el Tooltip esté fuera del div que se re-renderiza con el cambio de pestaña */} | ||||
|       <Tooltip id="seat-tooltip" /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,95 @@ | ||||
| // src/features/legislativas/provinciales/DipSenTickerWidget.tsx | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { CategoriaResumen, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| export const DipSenTickerWidget = () => { | ||||
|   const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ | ||||
|     queryKey: ['resumenProvincial'], | ||||
|     queryFn: getResumenProvincial, | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = useMemo(() => { | ||||
|     return parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|   }, [configData]); | ||||
|  | ||||
|   if (isLoading) return <div className="ticker-wrapper loading">Cargando resumen...</div>; | ||||
|   if (error || !categorias) return <div className="ticker-wrapper error">No hay datos disponibles.</div>; | ||||
|  | ||||
|   const categoriasFiltradas = categorias.filter(c => c.categoriaId !== 7); | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-wrapper"> | ||||
|       {categoriasFiltradas.map(categoria => { | ||||
|  | ||||
|         let displayResults: ResultadoTicker[] = categoria.resultados; | ||||
|  | ||||
|         if (categoria.resultados.length > cantidadAMostrar) { | ||||
|           const topParties = categoria.resultados.slice(0, cantidadAMostrar - 1); | ||||
|           const otherParties = categoria.resultados.slice(cantidadAMostrar - 1); | ||||
|           const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0); | ||||
|  | ||||
|           const otrosEntry: ResultadoTicker = { | ||||
|             id: `otros-${categoria.categoriaId}`, | ||||
|             nombre: 'Otros', | ||||
|             nombreCorto: 'Otros', | ||||
|             color: '#888888', | ||||
|             logoUrl: null, | ||||
|             votos: 0, | ||||
|             porcentaje: otrosPorcentaje, | ||||
|           }; | ||||
|  | ||||
|           displayResults = [...topParties, otrosEntry]; | ||||
|         } else { | ||||
|           displayResults = categoria.resultados.slice(0, cantidadAMostrar); | ||||
|         } | ||||
|  | ||||
|         return ( | ||||
|           <div key={categoria.categoriaId} className="ticker-card"> | ||||
|             <div className="ticker-header"> | ||||
|               <h3>{categoria.categoriaNombre}</h3> | ||||
|               <div className="ticker-stats"> | ||||
|                 <span>Mesas: <strong>{formatPercent(categoria.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong></span> | ||||
|                 <span>Part: <strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong></span> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div className="ticker-results"> | ||||
|               {displayResults.map(partido => ( | ||||
|                 <div key={partido.id} className="ticker-party"> | ||||
|                   <div className="party-logo"> | ||||
|                     <ImageWithFallback | ||||
|                       src={partido.logoUrl || undefined} | ||||
|                       fallbackSrc={`${assetBaseUrl}/default-avatar.png`} | ||||
|                       alt={`Logo de ${partido.nombre}`} | ||||
|                     /> | ||||
|                   </div> | ||||
|                   <div className="party-details"> | ||||
|                     <div className="party-info"> | ||||
|                       <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                       <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|                     </div> | ||||
|                     <div className="party-bar-background"> | ||||
|                       <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|                     </div> | ||||
|                   </div> | ||||
|                 </div> | ||||
|               ))} | ||||
|             </div> | ||||
|           </div> | ||||
|         ); | ||||
|       })} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,127 @@ | ||||
| // src/features/legislativas/provinciales/DiputadosPorSeccionWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), | ||||
| }; | ||||
|  | ||||
| const CATEGORIA_ID = 6; // ID para Diputados | ||||
|  | ||||
| export const DiputadosPorSeccionWidget = () => { | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // Ahora usamos useQuery para obtener las secciones filtradas | ||||
|   const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({ | ||||
|     queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché | ||||
|     queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría | ||||
|   }); | ||||
|  | ||||
|   // useEffect para establecer la primera sección por defecto | ||||
|   useEffect(() => { | ||||
|     if (secciones.length > 0 && !selectedSeccion) { | ||||
|       // Ordenamos aquí solo para la selección inicial | ||||
|       const orden = new Map([ | ||||
|           ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|           ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|       ]); | ||||
|       const getOrden = (nombre: string) => { | ||||
|           const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|           return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|       }; | ||||
|       const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|        | ||||
|       setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre }); | ||||
|     } | ||||
|   }, [secciones, selectedSeccion]); | ||||
|  | ||||
|   const seccionOptions = useMemo(() => | ||||
|     secciones | ||||
|       .map(s => ({ value: s.id, label: s.nombre })) | ||||
|       .sort((a, b) => { // Mantenemos el orden en el dropdown | ||||
|           const orden = new Map([ | ||||
|               ['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3], | ||||
|               ['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7] | ||||
|           ]); | ||||
|           return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99); | ||||
|       }), | ||||
|   [secciones]); | ||||
|  | ||||
|   const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({ | ||||
|     queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedSeccion, | ||||
|   }); | ||||
|  | ||||
|   const resultados = data?.resultados || []; | ||||
|  | ||||
|   let displayResults: ResultadoTicker[] = resultados; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-diputados-${selectedSeccion?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>DIPUTADOS POR SECCIÓN</h3> | ||||
|         <Select | ||||
|           options={seccionOptions} | ||||
|           value={selectedSeccion} | ||||
|           onChange={(option) => setSelectedSeccion(option)} | ||||
|           isLoading={isLoadingSecciones} | ||||
|           placeholder="Seleccionar sección..." | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>} | ||||
|         {!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,84 @@ | ||||
| // src/features/legislativas/provinciales/DiputadosTickerWidget.tsx | ||||
| import { useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { CategoriaResumen, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const CATEGORIA_ID = 6; // ID para Diputados | ||||
|  | ||||
| export const DiputadosTickerWidget = () => { | ||||
|   const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ | ||||
|     queryKey: ['resumenProvincial'], | ||||
|     queryFn: getResumenProvincial, | ||||
|     refetchInterval: 30000, | ||||
|   }); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // Usamos useMemo para encontrar los datos específicos de Diputados | ||||
|   const diputadosData = useMemo(() => { | ||||
|     return categorias?.find(c => c.categoriaId === CATEGORIA_ID); | ||||
|   }, [categorias]); | ||||
|  | ||||
|   if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>; | ||||
|   if (error || !diputadosData) return <div className="ticker-card error"><p>Datos de Diputados no disponibles.</p></div>; | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = diputadosData.resultados; | ||||
|   if (diputadosData.resultados.length > cantidadAMostrar) { | ||||
|     const topParties = diputadosData.resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = diputadosData.resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-diputados`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else { | ||||
|     displayResults = diputadosData.resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>RESUMEN DE {diputadosData.categoriaNombre}</h3> | ||||
|         <div className="ticker-stats"> | ||||
|           <span>Mesas: <strong>{formatPercent(diputadosData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span> | ||||
|           <span>Part: <strong>{formatPercent(diputadosData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,121 @@ | ||||
| // src/features/legislativas/provinciales/DiputadosWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), | ||||
| }; | ||||
|  | ||||
| // Constante para la categoría de este widget | ||||
| const CATEGORIA_ID = 6; // Diputados | ||||
|  | ||||
| export const DiputadosWidget = () => { | ||||
|   const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   // Usamos la clave de configuración del Ticker, ya que es para Senadores/Diputados | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({ | ||||
|     queryKey: ['municipios', CATEGORIA_ID], // Key única para la lista de municipios de diputados | ||||
|     queryFn: () => getMunicipios(CATEGORIA_ID), // Pide solo los municipios que votan diputados | ||||
|   }); | ||||
|  | ||||
|   // useEffect para establecer "LA PLATA" por defecto | ||||
|   useEffect(() => { | ||||
|     if (municipios.length > 0 && !selectedMunicipio) { | ||||
|       const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'LA PLATA'); | ||||
|       if (laPlata) { | ||||
|         setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre }); | ||||
|       } else if (municipios.length > 0) { | ||||
|         // Si no está La Plata, seleccionamos el primero de la lista | ||||
|         setSelectedMunicipio({ value: municipios[0].id, label: municipios[0].nombre }); | ||||
|       } | ||||
|     } | ||||
|   }, [municipios, selectedMunicipio]); | ||||
|  | ||||
|   const municipioOptions = useMemo(() => | ||||
|     municipios | ||||
|       .map(m => ({ value: m.id, label: m.nombre })) | ||||
|       .sort((a, b) => a.label.localeCompare(b.label)), | ||||
|     [municipios]); | ||||
|  | ||||
|   const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({ | ||||
|     queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedMunicipio, | ||||
|   }); | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = resultados || []; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-diputados-${selectedMunicipio?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>DIPUTADOS POR MUNICIPIO</h3> | ||||
|         <Select | ||||
|           options={municipioOptions} | ||||
|           value={selectedMunicipio} | ||||
|           onChange={(option) => setSelectedMunicipio(option)} | ||||
|           isLoading={isLoadingMunicipios} | ||||
|           placeholder="Buscar municipio..." | ||||
|           isClearable | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>} | ||||
|         {!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|               <div className="party-candidate-name"> | ||||
|                 {partido.nombreCandidato} | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,324 @@ | ||||
| /* src/features/legislativas/provinciales/MapaBsAs.css */ | ||||
| :root { | ||||
|   --primary-accent-color: #0073e6; | ||||
|   --background-panel-color: #ffffff; | ||||
|   --border-color: #dee2e6; | ||||
|   --text-color: #212529; | ||||
|   --text-color-muted: #6c757d; | ||||
|   --progress-bar-background: #e9ecef; | ||||
|   --scrollbar-thumb-color: #ced4da; | ||||
|   --scrollbar-track-color: #f1f1f1; | ||||
|   --map-background-color: #f8f9fa; | ||||
| } | ||||
|  | ||||
| .mapa-wrapper { | ||||
|   display: flex; | ||||
|   gap: 1.5rem; | ||||
|   background-color: #ffffff; | ||||
|   border: 1px solid var(--border-color); | ||||
|   box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|   padding: 1rem; | ||||
|   border-radius: 8px; | ||||
|   max-width: 960px; | ||||
|   margin: auto; | ||||
|   height: 88vh; | ||||
|   min-height: 650px; | ||||
|   font-size: 14px;  | ||||
| } | ||||
|  | ||||
| .mapa-container { | ||||
|   flex: 0 0 70%; | ||||
|   height: 100%; | ||||
|   border: 1px solid var(--border-color); | ||||
|   border-radius: 8px; | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
|   background-color: var(--map-background-color); | ||||
| } | ||||
|  | ||||
| .mapa-container .rsm-svg { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| /* APLICA LA TRANSICIÓN POR DEFECTO A ZOOMABLEGROUP */ | ||||
| .mapa-container .rsm-zoomable-group { | ||||
|   transition: transform 400ms ease-in-out; | ||||
| } | ||||
|  | ||||
| /* DESACTIVA LA TRANSICIÓN CUANDO SE ESTÁ ARRASTRANDO */ | ||||
| .mapa-container .rsm-zoomable-group.panning { | ||||
|   transition: none; | ||||
| } | ||||
|  | ||||
| .rsm-geography { | ||||
|   transition: opacity 0.3s ease, transform 0.2s ease, filter 0.2s ease, fill 0.3s ease; | ||||
|   cursor: pointer; | ||||
|   stroke: #b0b0b0; | ||||
|   stroke-width: 0.5px; | ||||
| } | ||||
|  | ||||
| .rsm-geography:hover { | ||||
|   stroke: var(--primary-accent-color); | ||||
|   stroke-width: 1.5px; | ||||
|   filter: brightness(1.05); | ||||
| } | ||||
|  | ||||
| .rsm-geography.selected { | ||||
|   stroke: #333; | ||||
|   stroke-width: 2px; | ||||
|   filter: none; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .rsm-geography.faded { | ||||
|   opacity: 0.25; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .info-panel { | ||||
|   flex: 1; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   overflow-y: auto; | ||||
|   min-height: 0; | ||||
|   background-color: var(--background-panel-color); | ||||
|   border-radius: 8px; | ||||
|   padding: 1rem; | ||||
|   border: none; | ||||
|   font-size: 1em; | ||||
| } | ||||
|  | ||||
| .info-panel::-webkit-scrollbar { width: 8px; } | ||||
| .info-panel::-webkit-scrollbar-track { background: var(--scrollbar-track-color); border-radius: 4px; } | ||||
| .info-panel::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-color); border-radius: 4px; border: 2px solid var(--scrollbar-track-color); } | ||||
| .info-panel::-webkit-scrollbar-thumb:hover { background-color: #adb5bd; } | ||||
|  | ||||
| .info-panel h3 { | ||||
|   margin-top: 0; | ||||
|   color: var(--primary-accent-color); | ||||
|   border-bottom: 2px solid var(--border-color); | ||||
|   padding-bottom: 0.5rem; | ||||
|   font-size: 1.25rem; | ||||
| } | ||||
|  | ||||
| .info-panel p { | ||||
|   color: var(--text-color-muted); | ||||
|   font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .reset-button-panel { | ||||
|   background: none; border: 1px solid var(--primary-accent-color); color: var(--primary-accent-color); padding: 0.5rem 1rem; border-radius: 5px; cursor: pointer; transition: all 0.2s; margin-bottom: 1rem; align-self: flex-start; font-size: 0.9rem; | ||||
| } | ||||
| .reset-button-panel:hover { background-color: var(--primary-accent-color); color: white; } | ||||
|  | ||||
| .detalle-placeholder {  | ||||
|   text-align: center;  | ||||
|   margin: auto 0;  | ||||
| } | ||||
| .detalle-loading, .detalle-error { text-align: center; margin: auto 0; color: var(--text-color-muted); } | ||||
| .detalle-metricas { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   font-size: 0.9rem; | ||||
|   padding-bottom: 1rem; | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| .resultados-lista { | ||||
|   list-style: none; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
| } | ||||
| .resultados-lista li { | ||||
|   margin-bottom: 1.1rem;  | ||||
| } | ||||
| .resultado-info { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: baseline; | ||||
|   margin-bottom: 0.35rem; | ||||
|   font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .partido-nombre { | ||||
|   font-weight: 500; | ||||
|   color: #343a40; | ||||
|   white-space: nowrap;  | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   padding-right: 1.5rem;  | ||||
| } | ||||
|  | ||||
| .partido-votos { | ||||
|   font-weight: 400; | ||||
|   color: var(--text-color-muted); | ||||
|   text-align: right; | ||||
|   white-space: nowrap; | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .progress-bar { | ||||
|   height: 6px; | ||||
|   background-color: var(--progress-bar-background); | ||||
|   border-radius: 3px; | ||||
|   overflow: hidden; | ||||
| } | ||||
| .progress-fill { | ||||
|   height: 100%; | ||||
|   background-color: var(--primary-accent-color); | ||||
|   border-radius: 3px; | ||||
|   transition: width 0.5s ease-out; | ||||
| } | ||||
|  | ||||
| .spinner { width: 40px; height: 40px; border: 4px solid var(--border-color); border-top-color: var(--primary-accent-color); border-radius: 50%; animation: spin 1s linear infinite; margin: 1rem auto; } | ||||
| @keyframes spin { to { transform: rotate(360deg); } } | ||||
|  | ||||
| .map-controls { | ||||
|   position: absolute; | ||||
|   top: 15px; | ||||
|   right: 15px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 5px; | ||||
|   z-index: 10; | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS PARA EL BOTÓN DE "VOLVER" (VISTA DESKTOP) --- */ | ||||
| .map-controls button { | ||||
|   /* Se elimina el ancho y alto fijos para que el botón se ajuste al texto. */ | ||||
|   width: auto; | ||||
|   height: auto; | ||||
|   /* Se define un padding para dar espacio interno al texto. */ | ||||
|   padding: 0.5rem 1rem; | ||||
|   /* Se ajusta el tamaño de fuente para el texto. */ | ||||
|   font-size: 0.9rem; | ||||
|   font-weight: bold; | ||||
|   background-color: #ffffff; | ||||
|   color: #333; | ||||
|   border: 1px solid var(--border-color); | ||||
|   box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); | ||||
|   border-radius: 4px; | ||||
|   cursor: pointer; | ||||
|   transition: all 0.2s; | ||||
|   /* Se eliminan las propiedades de centrado de íconos que ya no son necesarias. */ | ||||
| } | ||||
| .map-controls button:hover { | ||||
|   background-color: #f8f9fa; | ||||
|   border-color: var(--primary-accent-color); | ||||
|   color: var(--primary-accent-color); | ||||
| } | ||||
|  | ||||
| .legend { | ||||
|   padding-top: 1rem; | ||||
|   border-top: 1px solid var(--border-color); | ||||
| } | ||||
| .legend h4 { | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 0.75rem; | ||||
|   font-size: 1rem;  | ||||
| } | ||||
|  | ||||
| #root .legend h4 { | ||||
|   font-size: 1.15rem; | ||||
| } | ||||
|  | ||||
| .legend-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   margin-bottom: 0.35rem;  | ||||
|   font-size: 0.75rem; | ||||
| } | ||||
| .legend-color-box { width: 16px; height: 16px; margin-right: 8px; border-radius: 3px; border: 1px solid #ccc; } | ||||
|  | ||||
| /* --- ESTILOS PARA RESPONSIVIDAD MÓVIL --- */ | ||||
| @media (max-width: 992px) { | ||||
|   .mapa-wrapper { | ||||
|     flex-direction: column; | ||||
|     height: auto; | ||||
|     min-height: 100vh; | ||||
|     padding: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .mapa-container { | ||||
|     flex-basis: auto; | ||||
|     width: 100%; | ||||
|     height: 50vh; | ||||
|     min-height: 400px; | ||||
|   } | ||||
|  | ||||
|   .info-panel { | ||||
|     flex-basis: auto; | ||||
|     width: 100%; | ||||
|     height: auto; | ||||
|     overflow-y: visible; | ||||
|     box-sizing: border-box; | ||||
|   } | ||||
|  | ||||
|   .map-controls { | ||||
|     top: 10px; | ||||
|     right: 10px; | ||||
|   } | ||||
|  | ||||
|   /* --- ESTILOS PARA EL BOTÓN DE "VOLVER" (VISTA MÓVIL) --- */ | ||||
|   .map-controls button { | ||||
|     /* Se elimina el ancho y alto fijos para que el botón se ajuste al texto. */ | ||||
|     width: auto; | ||||
|     height: auto; | ||||
|     /* Se ajusta el padding para que sea un buen objetivo táctil (tappable). */ | ||||
|     padding: 0.6rem 1.2rem; | ||||
|     /* Un tamaño de fuente legible en móviles. */ | ||||
|     font-size: 1rem; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS PARA EL SELECTOR DE CATEGORÍA --- */ | ||||
| .mapa-categoria-selector { | ||||
|   display: flex; | ||||
|   margin-bottom: 1.5rem; | ||||
| } | ||||
|  | ||||
| .mapa-categoria-combobox { | ||||
|   width: 100%; | ||||
|   padding: 0.75rem 1rem; | ||||
|   font-size: 1rem; | ||||
|   font-weight: 500; | ||||
|   color: var(--text-color); | ||||
|   background-color: #f8f9fa; | ||||
|   border: 1px solid var(--border-color); | ||||
|   border-radius: 6px; | ||||
|   cursor: pointer; | ||||
|   appearance: none; | ||||
|   background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%230073e6%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.4-12.9z%22%2F%3E%3C%2Fsvg%3E'); | ||||
|   background-repeat: no-repeat; | ||||
|   background-position: right 1rem center; | ||||
|   background-size: 0.8em; | ||||
|   transition: all 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| .mapa-categoria-combobox:hover { | ||||
|   border-color: var(--primary-accent-color); | ||||
|   background-color: #e9ecef; | ||||
| } | ||||
|  | ||||
| .mapa-categoria-combobox:focus { | ||||
|   outline: none; | ||||
|   border-color: var(--primary-accent-color); | ||||
|   box-shadow: 0 0 0 2px rgba(0, 115, 230, 0.25); | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS PARA SECCIONES NO CLICLEABLES --- */ | ||||
| .rsm-geography.no-results { | ||||
|     pointer-events: none; /* Ignora todos los eventos del ratón (click, hover, etc.) */ | ||||
|     cursor: default;      /* Muestra el cursor por defecto en lugar de la mano */ | ||||
| } | ||||
|  | ||||
| /* Opcional pero recomendado: modificar la regla :hover para que no afecte a las secciones no clicleables */ | ||||
| .rsm-geography:not(.no-results):hover { | ||||
|     stroke: var(--primary-accent-color); | ||||
|     stroke-width: 1.5px; | ||||
|     filter: brightness(1.05); | ||||
| } | ||||
| @@ -0,0 +1,416 @@ | ||||
| // src/features/legislativas/provinciales/MapaBsAs.tsx | ||||
| import { useState, useMemo, useCallback, useEffect } from 'react'; | ||||
| import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import axios from 'axios'; | ||||
| import { feature } from 'topojson-client'; | ||||
| import type { Feature, Geometry } from 'geojson'; | ||||
| import { geoCentroid } from 'd3-geo'; | ||||
| import { API_BASE_URL, assetBaseUrl } from '../../../apiService'; | ||||
| import './MapaBsAs.css'; | ||||
|  | ||||
| // --- Interfaces y Tipos --- | ||||
| type PointTuple = [number, number]; | ||||
|  | ||||
| interface MapaBsAsProps { | ||||
|   focoMunicipio?: string; | ||||
|   focoCategoria?: string; | ||||
| } | ||||
|  | ||||
| interface ResultadoMapa { | ||||
|   ambitoId: number; | ||||
|   departamentoNombre: string; | ||||
|   agrupacionGanadoraId: string; | ||||
|   colorGanador: string | null; | ||||
| } | ||||
|  | ||||
| interface ResultadoDetalladoMunicipio { | ||||
|   municipioNombre: string; | ||||
|   ultimaActualizacion: string; | ||||
|   porcentajeEscrutado: number; | ||||
|   porcentajeParticipacion: number; | ||||
|   resultados: { id: string; nombre: string; votos: number; porcentaje: number; color: string | null; }[]; | ||||
|   votosAdicionales: { enBlanco: number; nulos: number; recurridos: number }; | ||||
| } | ||||
|  | ||||
| interface Agrupacion { | ||||
|   id: string; | ||||
|   nombre: string; | ||||
| } | ||||
|  | ||||
| interface Categoria { id: number; nombre: string; } | ||||
| interface PartidoProperties { departamento: string; } | ||||
| type PartidoGeography = Feature<Geometry, PartidoProperties> & { rsmKey: string }; | ||||
|  | ||||
| // --- Constantes --- | ||||
| const MIN_ZOOM = 1; | ||||
| const MAX_ZOOM = 8; | ||||
| const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -1000], [1100, 800]]; | ||||
| const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM }; | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
|  | ||||
| const CATEGORIAS: Categoria[] = [ | ||||
|   { id: 6, nombre: 'Diputados' }, | ||||
|   { id: 5, nombre: 'Senadores' }, | ||||
|   { id: 7, nombre: 'Concejales' } | ||||
| ]; | ||||
|  | ||||
| // --- Helper de Normalización --- | ||||
| const normalizarTexto = (texto: string = ''): string => { | ||||
|   return texto | ||||
|     .trim() | ||||
|     .toUpperCase() | ||||
|     .normalize("NFD") // Separa los acentos de las letras | ||||
|     .replace(/[\u0300-\u036f]/g, ""); // Elimina los acentos | ||||
| }; | ||||
|  | ||||
| // --- Componente Principal --- | ||||
| const MapaBsAs = ({ focoMunicipio, focoCategoria }: MapaBsAsProps) => { | ||||
|   // --- LÓGICA DE ESTADO SIMPLIFICADA --- | ||||
|   const categoriaInicial = useMemo(() => { | ||||
|     const catNorm = focoCategoria?.toLowerCase(); | ||||
|     if (catNorm === 'senadores') return 5; | ||||
|     if (catNorm === 'concejales') return 7; | ||||
|     return 6; | ||||
|   }, [focoCategoria]); | ||||
|  | ||||
|   const [position, setPosition] = useState(INITIAL_POSITION); | ||||
|   const [selectedAmbitoId, setSelectedAmbitoId] = useState<number | null>(null); | ||||
|   const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(categoriaInicial); | ||||
|   const [tooltipContent, setTooltipContent] = useState(''); | ||||
|   const [isPanning, setIsPanning] = useState(false); | ||||
|  | ||||
|   // Sincroniza el estado si la prop cambia. Esto es para cuando el widget ya está montado | ||||
|   // y recibe nuevas props (no ocurrirá en tu caso actual, pero es buena práctica). | ||||
|   useEffect(() => { | ||||
|     setSelectedCategoriaId(categoriaInicial); | ||||
|   }, [categoriaInicial]); | ||||
|  | ||||
|   // --- QUERIES --- | ||||
|   const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapa[]>({ | ||||
|     queryKey: ['mapaResultadosPorMunicipio', selectedCategoriaId], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-municipio?categoriaId=${selectedCategoriaId}`)).data, | ||||
|   }); | ||||
|  | ||||
|   const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({ | ||||
|     queryKey: ['mapaGeoData'], | ||||
|     queryFn: async () => (await axios.get(`${assetBaseUrl}/partidos-bsas.topojson`)).data, | ||||
|   }); | ||||
|  | ||||
|   const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({ | ||||
|     queryKey: ['catalogoAgrupaciones'], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data, | ||||
|   }); | ||||
|  | ||||
|   const { nombresAgrupaciones, resultadosPorDepartamento } = useMemo(() => { | ||||
|     const nombresMap = new Map<string, string>(); | ||||
|     const resultadosMap = new Map<string, ResultadoMapa>(); | ||||
|     if (agrupacionesData) { | ||||
|       agrupacionesData.forEach((a) => nombresMap.set(a.id, a.nombre)); | ||||
|     } | ||||
|     if (resultadosData) { | ||||
|       resultadosData.forEach(r => { | ||||
|         if (r.departamentoNombre) { | ||||
|           resultadosMap.set(normalizarTexto(r.departamentoNombre), r); | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     return { nombresAgrupaciones: nombresMap, resultadosPorDepartamento: resultadosMap }; | ||||
|   }, [agrupacionesData, resultadosData]); | ||||
|  | ||||
|   const isLoading = isLoadingResultados || isLoadingAgrupaciones || isLoadingGeo; | ||||
|  | ||||
|   const handleReset = useCallback(() => { | ||||
|     setSelectedAmbitoId(null); | ||||
|     setPosition(INITIAL_POSITION); | ||||
|   }, []); | ||||
|  | ||||
|   // --- LÓGICA DE CLIC Y FOCO --- | ||||
|   const handleGeographyClick = useCallback((geo: PartidoGeography) => { | ||||
|     const departamentoNombreNormalizado = normalizarTexto(geo.properties.departamento); | ||||
|     const resultado = resultadosPorDepartamento.get(departamentoNombreNormalizado); | ||||
|     if (!resultado) return; | ||||
|  | ||||
|     if (selectedAmbitoId === resultado.ambitoId) { | ||||
|       handleReset(); | ||||
|     } else { | ||||
|       const centroid = geoCentroid(geo) as PointTuple; | ||||
|       setPosition({ center: centroid, zoom: 5 }); | ||||
|       setSelectedAmbitoId(resultado.ambitoId); | ||||
|     } | ||||
|   }, [selectedAmbitoId, handleReset, resultadosPorDepartamento]); | ||||
|  | ||||
|   // --- useEffect DE INICIALIZACIÓN --- | ||||
|   useEffect(() => { | ||||
|     if (isLoading || !focoMunicipio || selectedAmbitoId) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const geometries = geoData?.objects?.['departamentos-buenos_aires']?.geometries; | ||||
|     if (!geometries) return; | ||||
|  | ||||
|     const nombreFocoNormalizado = normalizarTexto(focoMunicipio); | ||||
|     const geoTargetTopo = geometries.find( | ||||
|       (g: any) => normalizarTexto(g.properties.departamento) === nombreFocoNormalizado | ||||
|     ); | ||||
|  | ||||
|     if (geoTargetTopo) { | ||||
|       if (resultadosPorDepartamento.has(nombreFocoNormalizado)) { | ||||
|         const geoTargetGeoJSON = feature(geoData, geoTargetTopo); | ||||
|         handleGeographyClick(geoTargetGeoJSON as unknown as PartidoGeography); | ||||
|       } | ||||
|     } | ||||
|     // Deshabilitamos la regla para que solo se ejecute cuando isLoading cambia a false. | ||||
|     // eslint-disable-next-line react-hooks/exhaustive-deps | ||||
|   }, [isLoading]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (categoriaInicial !== selectedCategoriaId) { | ||||
|       setSelectedCategoriaId(categoriaInicial); | ||||
|       handleReset(); | ||||
|     } | ||||
|   }, [categoriaInicial, selectedCategoriaId, handleReset]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset(); | ||||
|     window.addEventListener('keydown', handleKeyDown); | ||||
|     return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|   }, [handleReset]); | ||||
|  | ||||
|   const renderGeography = useCallback((geo: PartidoGeography, isSelectedGeo: boolean = false) => { | ||||
|     const departamentoNombreNormalizado = normalizarTexto(geo.properties.departamento); | ||||
|     const resultado = resultadosPorDepartamento.get(departamentoNombreNormalizado); | ||||
|     const isClickable = !!resultado; | ||||
|     const isSelected = isSelectedGeo || (selectedAmbitoId !== null && selectedAmbitoId === resultado?.ambitoId); | ||||
|     const isFaded = !isSelectedGeo && selectedAmbitoId !== null && !isSelected; | ||||
|  | ||||
|     const nombreAgrupacionGanadora = resultado | ||||
|       ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) | ||||
|       : 'Sin datos'; | ||||
|  | ||||
|     return ( | ||||
|       <Geography | ||||
|         key={geo.rsmKey + (isSelectedGeo ? '-selected' : '')} | ||||
|         geography={geo} | ||||
|         className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`} | ||||
|         fill={resultado?.colorGanador || DEFAULT_MAP_COLOR} | ||||
|         onClick={isClickable ? () => handleGeographyClick(geo) : undefined} | ||||
|         onMouseEnter={() => { | ||||
|           if (isClickable) { | ||||
|             setTooltipContent(`${geo.properties.departamento}: ${nombreAgrupacionGanadora}`); | ||||
|           } | ||||
|         }} | ||||
|         onMouseLeave={() => setTooltipContent("")} | ||||
|       /> | ||||
|     ); | ||||
|   }, [resultadosPorDepartamento, selectedAmbitoId, nombresAgrupaciones, handleGeographyClick]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (isLoading || !focoMunicipio || selectedAmbitoId) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const geometries = geoData?.objects?.['departamentos-buenos_aires']?.geometries; | ||||
|     if (!geometries) return; | ||||
|  | ||||
|     const nombreFocoNormalizado = normalizarTexto(focoMunicipio); | ||||
|     const geoTargetTopo = geometries.find( | ||||
|       (g: any) => normalizarTexto(g.properties.departamento) === nombreFocoNormalizado | ||||
|     ); | ||||
|  | ||||
|     if (geoTargetTopo && resultadosPorDepartamento.has(nombreFocoNormalizado)) { | ||||
|       const geoTargetGeoJSON = feature(geoData, geoTargetTopo); | ||||
|       handleGeographyClick(geoTargetGeoJSON as unknown as PartidoGeography); | ||||
|     } | ||||
|   }, [isLoading, focoMunicipio, selectedCategoriaId]); | ||||
|  | ||||
|   const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|     if (newPosition.zoom <= MIN_ZOOM) { | ||||
|       if (position.zoom > MIN_ZOOM || selectedAmbitoId !== null) { | ||||
|         handleReset(); | ||||
|       } | ||||
|       return; | ||||
|     } | ||||
|     if (newPosition.zoom < position.zoom && selectedAmbitoId !== null) { | ||||
|       setSelectedAmbitoId(null); | ||||
|     } | ||||
|     setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom }); | ||||
|   }; | ||||
|  | ||||
|   const handleZoomIn = () => { | ||||
|     if (position.zoom < MAX_ZOOM) { | ||||
|       setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset(); | ||||
|     window.addEventListener('keydown', handleKeyDown); | ||||
|     return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|   }, [handleReset]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-wrapper"> | ||||
|       <div className="mapa-container"> | ||||
|         {isLoading ? <div className="spinner"></div> : ( | ||||
|           <ComposableMap | ||||
|             key={selectedCategoriaId} | ||||
|             projection="geoMercator" | ||||
|             projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }} | ||||
|             className="rsm-svg" | ||||
|             data-tooltip-id="partido-tooltip" | ||||
|           > | ||||
|             <ZoomableGroup | ||||
|               center={position.center} | ||||
|               zoom={position.zoom} | ||||
|               className={isPanning ? 'panning' : ''} | ||||
|               onMoveStart={() => setIsPanning(true)} | ||||
|               onMoveEnd={(newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|                 setIsPanning(false); | ||||
|                 handleMoveEnd(newPosition); | ||||
|               }} | ||||
|               translateExtent={TRANSLATE_EXTENT} | ||||
|               minZoom={MIN_ZOOM} | ||||
|               maxZoom={MAX_ZOOM} | ||||
|               filterZoomEvent={(e: WheelEvent) => { | ||||
|                 if (e.deltaY > 0) { | ||||
|                   handleReset(); | ||||
|                 } else if (e.deltaY < 0) { | ||||
|                   handleZoomIn(); | ||||
|                 } | ||||
|                 return true; | ||||
|               }} | ||||
|             > | ||||
|               {geoData && ( | ||||
|                 <Geographies geography={geoData}> | ||||
|                   {({ geographies }: { geographies: PartidoGeography[] }) => { | ||||
|                     const selectedGeo = selectedAmbitoId | ||||
|                       ? geographies.find(geo => { | ||||
|                         const resultado = resultadosPorDepartamento.get(normalizarTexto(geo.properties.departamento)); | ||||
|                         return resultado?.ambitoId === selectedAmbitoId; | ||||
|                       }) | ||||
|                       : null; | ||||
|  | ||||
|                     return ( | ||||
|                       <> | ||||
|                         {geographies.map(geo => (!selectedGeo || geo.rsmKey !== selectedGeo.rsmKey) ? renderGeography(geo) : null)} | ||||
|                         {selectedGeo && renderGeography(selectedGeo, true)} | ||||
|                       </> | ||||
|                     ); | ||||
|                   }} | ||||
|                 </Geographies> | ||||
|               )} | ||||
|             </ZoomableGroup> | ||||
|           </ComposableMap> | ||||
|         )} | ||||
|         <Tooltip id="partido-tooltip" content={tooltipContent} /> | ||||
|         {selectedAmbitoId !== null && <ControlesMapa onReset={handleReset} />} | ||||
|       </div> | ||||
|       <div className="info-panel"> | ||||
|         <div className="mapa-categoria-selector"> | ||||
|           <select | ||||
|             className="mapa-categoria-combobox" | ||||
|             value={selectedCategoriaId} | ||||
|             onChange={(e) => { | ||||
|               // --- LÓGICA DE CAMBIO DE CATEGORÍA --- | ||||
|               // Limpiamos el foco de municipio al cambiar de categoría | ||||
|               setSelectedAmbitoId(null); | ||||
|               setPosition(INITIAL_POSITION); | ||||
|               setSelectedCategoriaId(Number(e.target.value)); | ||||
|             }} | ||||
|           > | ||||
|             {CATEGORIAS.map(cat => ( | ||||
|               <option key={cat.id} value={cat.id}>{cat.nombre}</option> | ||||
|             ))} | ||||
|           </select> | ||||
|         </div> | ||||
|         <DetalleMunicipio ambitoId={selectedAmbitoId} onReset={handleReset} categoriaId={selectedCategoriaId} /> | ||||
|         <Legend resultados={resultadosPorDepartamento} nombresAgrupaciones={nombresAgrupaciones} /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // --- Sub-componentes --- | ||||
| const ControlesMapa = ({ onReset }: { onReset: () => void }) => ( | ||||
|  | ||||
|   <div className="map-controls"> | ||||
|     <button onClick={onReset}>← VOLVER</button> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const DetalleMunicipio = ({ ambitoId, onReset, categoriaId }: { ambitoId: number | null; onReset: () => void; categoriaId: number; }) => { | ||||
|   const { data, isLoading, error } = useQuery<ResultadoDetalladoMunicipio>({ | ||||
|     queryKey: ['municipioDetalle', ambitoId, categoriaId], | ||||
|     queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/municipio/${ambitoId}?categoriaId=${categoriaId}`)).data, | ||||
|     enabled: !!ambitoId, | ||||
|   }); | ||||
|  | ||||
|   if (!ambitoId) return (<div className="detalle-placeholder"><h3>Provincia de Buenos Aires</h3><p>Seleccione un municipio en el mapa para ver los resultados detallados.</p></div>); | ||||
|   if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados...</p></div>); | ||||
|   if (error) return <div className="detalle-error">Error al cargar los datos del municipio.</div>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="detalle-content"> | ||||
|       <button className="reset-button-panel" onClick={onReset}>← VOLVER</button> | ||||
|       <h3>{data?.municipioNombre}</h3> | ||||
|       <div className="detalle-metricas"> | ||||
|         <span><strong>Escrutado:</strong> {(data?.porcentajeEscrutado ?? 0).toFixed(2)}%</span> | ||||
|         <span><strong>Participación:</strong> {(data?.porcentajeParticipacion ?? 0).toFixed(2)}%</span> | ||||
|       </div> | ||||
|       <ul className="resultados-lista"> | ||||
|         {(data?.resultados ?? []).map((r, index) => ( | ||||
|           <li key={`${r.nombre}-${index}`}> | ||||
|             <div className="resultado-info"> | ||||
|               <span className="partido-nombre">{r.nombre}</span> | ||||
|               <span className="partido-votos">{r.votos.toLocaleString('es-AR')} ({r.porcentaje.toFixed(2)}%)</span> | ||||
|             </div> | ||||
|             <div className="progress-bar"> | ||||
|               <div | ||||
|                 className="progress-fill" | ||||
|                 style={{ | ||||
|                   width: `${r.porcentaje}%`, | ||||
|                   backgroundColor: r.color || DEFAULT_MAP_COLOR | ||||
|                 }} | ||||
|               ></div> | ||||
|             </div> | ||||
|           </li > | ||||
|         ))} | ||||
|       </ul > | ||||
|     </div > | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const Legend = ({ resultados, nombresAgrupaciones }: { resultados: Map<string, ResultadoMapa>, nombresAgrupaciones: Map<string, string> }) => { | ||||
|  | ||||
|   const legendItems = useMemo(() => { | ||||
|     const ganadoresUnicos = new Map<string, { nombre: string; color: string }>(); | ||||
|  | ||||
|     resultados.forEach(resultado => { | ||||
|       if (resultado.colorGanador && !ganadoresUnicos.has(resultado.agrupacionGanadoraId)) { | ||||
|         ganadoresUnicos.set(resultado.agrupacionGanadoraId, { | ||||
|           nombre: nombresAgrupaciones.get(resultado.agrupacionGanadoraId) || 'Desconocido', | ||||
|           color: resultado.colorGanador | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return Array.from(ganadoresUnicos.values()); | ||||
|  | ||||
|   }, [resultados, nombresAgrupaciones]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="legend"> | ||||
|       <h4>Leyenda de Ganadores</h4> | ||||
|       {legendItems.map(item => ( | ||||
|         <div key={item.nombre} className="legend-item"> | ||||
|           <div className="legend-color-box" style={{ backgroundColor: item.color }} /> | ||||
|           <span>{item.nombre}</span> | ||||
|         </div> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default MapaBsAs; | ||||
| @@ -0,0 +1,306 @@ | ||||
| // src/features/legislativas/provinciales/MapaBsAsSecciones.tsx | ||||
| import { useState, useMemo, useCallback, useEffect } from 'react'; | ||||
| import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import axios from 'axios'; | ||||
| import { geoCentroid } from 'd3-geo'; | ||||
| import { getDetalleSeccion, API_BASE_URL, assetBaseUrl } from '../../../apiService'; | ||||
| import { type ResultadoDetalleSeccion } from '../../../apiService'; | ||||
| import './MapaBsAs.css'; | ||||
|  | ||||
| // --- Interfaces y Tipos --- | ||||
| type PointTuple = [number, number]; | ||||
| interface ResultadoMapaSeccion { | ||||
|   seccionId: string; | ||||
|   seccionNombre: string; | ||||
|   agrupacionGanadoraId: string | null; | ||||
|   colorGanador: string | null; | ||||
| } | ||||
| interface Agrupacion { id: string; nombre: string; } | ||||
| interface Categoria { id: number; nombre: string; } | ||||
| type SeccionGeography = { | ||||
|   rsmKey: string; | ||||
|   properties: { seccion: string; fna: string; }; | ||||
| }; | ||||
|  | ||||
| // --- Constantes --- | ||||
| const DEFAULT_MAP_COLOR = '#E0E0E0'; | ||||
| const CATEGORIAS: Categoria[] = [{ id: 5, nombre: 'Senadores' }, { id: 6, nombre: 'Diputados' }]; | ||||
| const SECCION_ID_TO_ROMAN: Record<string, string> = { '1': 'I', '2': 'II', '3': 'III', '4': 'IV', '5': 'V', '6': 'VI', '7': 'VII', '8': 'VIII' }; | ||||
| const ROMAN_TO_SECCION_ID: Record<string, string> = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4', 'V': '5', 'VI': '6', 'VII': '7', 'VIII': '8' }; | ||||
| const NOMBRES_SECCIONES: Record<string, string> = { | ||||
|     'I': 'Sección Primera', 'II': 'Sección Segunda', 'III': 'Sección Tercera', 'IV': 'Sección Cuarta', | ||||
|     'V': 'Sección Quinta', 'VI': 'Sección Sexta', 'VII': 'Sección Séptima', 'VIII': 'Sección Capital' | ||||
| }; | ||||
| const MIN_ZOOM = 1; | ||||
| const MAX_ZOOM = 5; | ||||
| const TRANSLATE_EXTENT: [[number, number], [number, number]] = [[-100, -1000], [1100, 800]]; | ||||
| const INITIAL_POSITION = { center: [-60.5, -37.2] as PointTuple, zoom: MIN_ZOOM }; | ||||
|  | ||||
|  | ||||
| // --- Componente de Detalle --- | ||||
| const DetalleSeccion = ({ seccion, categoriaId, onReset }: { seccion: SeccionGeography | null, categoriaId: number, onReset: () => void }) => { | ||||
|     const seccionId = seccion ? ROMAN_TO_SECCION_ID[seccion.properties.seccion] : null; | ||||
|  | ||||
|     const { data: resultadosDetalle, isLoading, error } = useQuery<ResultadoDetalleSeccion[]>({ | ||||
|         queryKey: ['detalleSeccion', seccionId, categoriaId], | ||||
|         queryFn: () => getDetalleSeccion(seccionId!, categoriaId), | ||||
|         enabled: !!seccionId, | ||||
|     }); | ||||
|  | ||||
|     if (!seccion) { | ||||
|         return ( | ||||
|         <div className="detalle-placeholder"> | ||||
|             <h3>Resultados por Sección</h3> | ||||
|             <p>Haga clic en una sección del mapa para ver los resultados detallados.</p> | ||||
|         </div> | ||||
|         ); | ||||
|     } | ||||
|      | ||||
|     if (isLoading) return (<div className="detalle-loading"><div className="spinner"></div><p>Cargando resultados de la sección...</p></div>); | ||||
|     if (error) return <div className="detalle-error">Error al cargar los datos de la sección.</div>; | ||||
|  | ||||
|     const nombreSeccionLegible = NOMBRES_SECCIONES[seccion.properties.seccion] || "Sección Desconocida"; | ||||
|  | ||||
|     return ( | ||||
|         <div className="detalle-content"> | ||||
|         <button className="reset-button-panel" onClick={onReset}>← VOLVER</button> | ||||
|         <h3>{nombreSeccionLegible}</h3> | ||||
|         <ul className="resultados-lista"> | ||||
|             {resultadosDetalle?.map((r) => ( | ||||
|             <li key={r.id}> | ||||
|                 <div className="resultado-info"> | ||||
|                 <span className="partido-nombre">{r.nombre}</span> | ||||
|                 <span className="partido-votos">{r.votos.toLocaleString('es-AR')} ({r.porcentaje.toFixed(2)}%)</span> | ||||
|                 </div> | ||||
|                 <div className="progress-bar"> | ||||
|                 <div className="progress-fill" style={{ width: `${r.porcentaje}%`, backgroundColor: r.color || DEFAULT_MAP_COLOR }}></div> | ||||
|                 </div> | ||||
|             </li> | ||||
|             ))} | ||||
|         </ul> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| // --- Componente de Controles del Mapa --- | ||||
| const ControlesMapa = ({ onReset }: { onReset: () => void }) => ( | ||||
|   <div className="map-controls"> | ||||
|     <button onClick={onReset}>← VOLVER</button> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| // --- Componente Principal --- | ||||
| const MapaBsAsSecciones = () => { | ||||
|   const [position, setPosition] = useState(INITIAL_POSITION); | ||||
|   const [selectedCategoriaId, setSelectedCategoriaId] = useState<number>(6); | ||||
|   const [clickedSeccion, setClickedSeccion] = useState<SeccionGeography | null>(null); | ||||
|   const [tooltipContent, setTooltipContent] = useState(''); | ||||
|   const [isPanning, setIsPanning] = useState(false); | ||||
|  | ||||
|     const { data: geoData, isLoading: isLoadingGeo } = useQuery<any>({ | ||||
|         queryKey: ['mapaGeoDataSecciones'], | ||||
|         queryFn: async () => (await axios.get(`${assetBaseUrl}/secciones-electorales-pba.topojson`)).data, | ||||
|     }); | ||||
|  | ||||
|     const { data: resultadosData, isLoading: isLoadingResultados } = useQuery<ResultadoMapaSeccion[]>({ | ||||
|         queryKey: ['mapaResultadosPorSeccion', selectedCategoriaId], | ||||
|         queryFn: async () => (await axios.get(`${API_BASE_URL}/Resultados/mapa-por-seccion?categoriaId=${selectedCategoriaId}`)).data, | ||||
|     }); | ||||
|  | ||||
|     const { data: agrupacionesData, isLoading: isLoadingAgrupaciones } = useQuery<Agrupacion[]>({ | ||||
|         queryKey: ['catalogoAgrupaciones'], | ||||
|         queryFn: async () => (await axios.get(`${API_BASE_URL}/Catalogos/agrupaciones`)).data, | ||||
|     }); | ||||
|  | ||||
|     const { nombresAgrupaciones, resultadosPorSeccionRomana } = useMemo<{ | ||||
|         nombresAgrupaciones: Map<string, string>; | ||||
|         resultadosPorSeccionRomana: Map<string, ResultadoMapaSeccion>; | ||||
|     }>(( | ||||
|     ) => { | ||||
|         const nombresMap = new Map<string, string>(); | ||||
|         const resultadosMap = new Map<string, ResultadoMapaSeccion>(); | ||||
|  | ||||
|         if (agrupacionesData) { | ||||
|         agrupacionesData.forEach(a => nombresMap.set(a.id, a.nombre)); | ||||
|         } | ||||
|         if (resultadosData) { | ||||
|         resultadosData.forEach(r => { | ||||
|             const roman = SECCION_ID_TO_ROMAN[r.seccionId]; | ||||
|             if (roman) resultadosMap.set(roman, r); | ||||
|         }); | ||||
|         } | ||||
|         return { nombresAgrupaciones: nombresMap, resultadosPorSeccionRomana: resultadosMap }; | ||||
|     }, [agrupacionesData, resultadosData]); | ||||
|  | ||||
|   const isLoading = isLoadingGeo || isLoadingResultados || isLoadingAgrupaciones; | ||||
|  | ||||
|     const handleReset = useCallback(() => { | ||||
|         setClickedSeccion(null); | ||||
|         setPosition(INITIAL_POSITION); | ||||
|     }, []); | ||||
|  | ||||
|     const handleGeographyClick = useCallback((geo: SeccionGeography) => { | ||||
|         if (clickedSeccion?.rsmKey === geo.rsmKey) { | ||||
|         handleReset(); | ||||
|         } else { | ||||
|         const centroid = geoCentroid(geo as any) as PointTuple; | ||||
|         setPosition({ center: centroid, zoom: 2 }); | ||||
|         setClickedSeccion(geo); | ||||
|         } | ||||
|     }, [clickedSeccion, handleReset]); | ||||
|  | ||||
|     const handleMoveEnd = (newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|         if (newPosition.zoom <= MIN_ZOOM) { | ||||
|         if (position.zoom > MIN_ZOOM || clickedSeccion !== null) { | ||||
|             handleReset(); | ||||
|         } | ||||
|         return; | ||||
|         } | ||||
|         if (newPosition.zoom < position.zoom && clickedSeccion !== null) { | ||||
|         setClickedSeccion(null); | ||||
|         } | ||||
|         setPosition({ center: newPosition.coordinates, zoom: newPosition.zoom }); | ||||
|     }; | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const handleKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && handleReset(); | ||||
|         window.addEventListener('keydown', handleKeyDown); | ||||
|         return () => window.removeEventListener('keydown', handleKeyDown); | ||||
|     }, [handleReset]); | ||||
|  | ||||
|     const getSectionFillColor = (seccionRomana: string) => { | ||||
|         return resultadosPorSeccionRomana.get(seccionRomana)?.colorGanador || DEFAULT_MAP_COLOR; | ||||
|     }; | ||||
|  | ||||
|     const handleZoomIn = () => { | ||||
|         if (position.zoom < MAX_ZOOM) { | ||||
|         setPosition(p => ({ ...p, zoom: Math.min(p.zoom * 1.5, MAX_ZOOM) })); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|  | ||||
|   return ( | ||||
|     <div className="mapa-wrapper"> | ||||
|       <div className="mapa-container"> | ||||
|         {isLoading ? <div className="spinner"></div> : ( | ||||
|           <ComposableMap | ||||
|             key={selectedCategoriaId} | ||||
|             projection="geoMercator" | ||||
|             projectionConfig={{ scale: 4400, center: [-60.5, -37.2] }} | ||||
|             className="rsm-svg" | ||||
|             data-tooltip-id="seccion-tooltip" | ||||
|           > | ||||
|             <ZoomableGroup | ||||
|               center={position.center} | ||||
|               zoom={position.zoom} | ||||
|               onMoveEnd={(newPosition: { coordinates: PointTuple; zoom: number }) => { | ||||
|                 setIsPanning(false); | ||||
|                 handleMoveEnd(newPosition); | ||||
|               }} | ||||
|               minZoom={MIN_ZOOM} | ||||
|               maxZoom={MAX_ZOOM} | ||||
|               translateExtent={TRANSLATE_EXTENT} | ||||
|               className={isPanning ? 'panning' : ''} | ||||
|               onMoveStart={() => setIsPanning(true)} | ||||
|               filterZoomEvent={(e: WheelEvent) => { | ||||
|                 if (e.deltaY > 0) { | ||||
|                   handleReset(); | ||||
|                 } else if (e.deltaY < 0) { | ||||
|                   handleZoomIn(); | ||||
|                 } | ||||
|                 return true; | ||||
|               }} | ||||
|             > | ||||
|               {geoData && ( | ||||
|                 <Geographies geography={geoData}> | ||||
|                   {({ geographies }: { geographies: SeccionGeography[] }) => | ||||
|                     geographies.map((geo) => { | ||||
|                       const seccionRomana = geo.properties.seccion; | ||||
|                       const resultado = resultadosPorSeccionRomana.get(seccionRomana); | ||||
|                       const nombreGanador = resultado?.agrupacionGanadoraId ? nombresAgrupaciones.get(resultado.agrupacionGanadoraId) : 'Sin datos'; | ||||
|                       const isSelected = clickedSeccion?.rsmKey === geo.rsmKey; | ||||
|                       const isFaded = clickedSeccion && !isSelected; | ||||
|                       const isClickable = !!resultado; | ||||
|  | ||||
|                       return ( | ||||
|                         <Geography | ||||
|                           key={geo.rsmKey + (isSelected ? '-selected' : '')} | ||||
|                           geography={geo as any} | ||||
|                           data-tooltip-id="seccion-tooltip" | ||||
|                           onClick={isClickable ? () => handleGeographyClick(geo) : undefined} | ||||
|                           onMouseEnter={() => { | ||||
|                             if (isClickable) { | ||||
|                               const nombreSeccionLegible = NOMBRES_SECCIONES[geo.properties.seccion] || "Sección Desconocida"; | ||||
|                               setTooltipContent(`${nombreSeccionLegible}: ${nombreGanador}`); | ||||
|                             } | ||||
|                           }} | ||||
|                           onMouseLeave={() => setTooltipContent("")} | ||||
|                           className={`rsm-geography ${isSelected ? 'selected' : ''} ${isFaded ? 'faded' : ''} ${!isClickable ? 'no-results' : ''}`} | ||||
|                           fill={getSectionFillColor(seccionRomana)} | ||||
|                         /> | ||||
|                       ); | ||||
|                     }) | ||||
|                   } | ||||
|                 </Geographies> | ||||
|               )} | ||||
|             </ZoomableGroup> | ||||
|           </ComposableMap> | ||||
|         )} | ||||
|         {clickedSeccion && <ControlesMapa onReset={handleReset} />} | ||||
|         <Tooltip id="seccion-tooltip" content={tooltipContent} /> | ||||
|       </div> | ||||
|       <div className="info-panel"> | ||||
|         <div className="mapa-categoria-selector"> | ||||
|           <select | ||||
|             className="mapa-categoria-combobox" | ||||
|             value={selectedCategoriaId} | ||||
|             onChange={(e) => { | ||||
|               setSelectedCategoriaId(Number(e.target.value)); | ||||
|               handleReset(); | ||||
|             }} | ||||
|           > | ||||
|             {CATEGORIAS.map(cat => ( | ||||
|               <option key={cat.id} value={cat.id}> | ||||
|                 {cat.nombre} | ||||
|               </option> | ||||
|             ))} | ||||
|           </select> | ||||
|         </div> | ||||
|         <DetalleSeccion seccion={clickedSeccion} categoriaId={selectedCategoriaId} onReset={handleReset} /> | ||||
|         <LegendSecciones resultados={resultadosPorSeccionRomana} nombresAgrupaciones={nombresAgrupaciones} /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| // --- Sub-componente para la Leyenda (sin cambios) --- | ||||
| const LegendSecciones = ({ resultados, nombresAgrupaciones }: { resultados: Map<string, ResultadoMapaSeccion>, nombresAgrupaciones: Map<string, string> }) => { | ||||
|     const legendItems = useMemo(() => { | ||||
|         const ganadoresUnicos = new Map<string, { nombre: string; color: string }>(); | ||||
|         resultados.forEach(resultado => { | ||||
|         if (resultado.agrupacionGanadoraId && resultado.colorGanador && !ganadoresUnicos.has(resultado.agrupacionGanadoraId)) { | ||||
|             ganadoresUnicos.set(resultado.agrupacionGanadoraId, { | ||||
|             nombre: nombresAgrupaciones.get(resultado.agrupacionGanadoraId) || 'Desconocido', | ||||
|             color: resultado.colorGanador | ||||
|             }); | ||||
|         } | ||||
|         }); | ||||
|         return Array.from(ganadoresUnicos.values()); | ||||
|     }, [resultados, nombresAgrupaciones]); | ||||
|  | ||||
|     return ( | ||||
|         <div className="legend"> | ||||
|         <h4>Leyenda de Ganadores</h4> | ||||
|         {legendItems.map(item => ( | ||||
|             <div key={item.nombre} className="legend-item"> | ||||
|             <div className="legend-color-box" style={{ backgroundColor: item.color }} /> | ||||
|             <span>{item.nombre}</span> | ||||
|             </div> | ||||
|         ))} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export default MapaBsAsSecciones; | ||||
| @@ -0,0 +1,176 @@ | ||||
| // src/features/legislativas/provinciales/ResultaosRankingMunicipioWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getSeccionesElectorales, getRankingMunicipiosPorSeccion } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ApiResponseRankingMunicipio, RankingPartido } from '../../../types/types'; | ||||
| import './ResultadosTablaSeccionWidget.css'; | ||||
|  | ||||
| type DisplayMode = 'porcentaje' | 'votos' | 'ambos'; | ||||
| type DisplayOption = { | ||||
|     value: DisplayMode; | ||||
|     label: string; | ||||
| }; | ||||
|  | ||||
| const displayModeOptions: readonly DisplayOption[] = [ | ||||
|     { value: 'porcentaje', label: 'Ver Porcentajes' }, | ||||
|     { value: 'votos', label: 'Ver Votos' }, | ||||
|     { value: 'ambos', label: 'Ver Ambos' }, | ||||
| ]; | ||||
|  | ||||
| const customSelectStyles = { | ||||
|     control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }), | ||||
|     menu: (base: any) => ({ ...base, zIndex: 10 }), | ||||
| }; | ||||
|  | ||||
| const formatPercent = (porcentaje: number) => `${porcentaje.toFixed(2).replace('.', ',')}%`; | ||||
| // Nueva función para formatear votos con separador de miles | ||||
| const formatVotos = (votos: number) => votos.toLocaleString('es-AR'); | ||||
|  | ||||
| // --- NUEVO COMPONENTE HELPER PARA RENDERIZAR CELDAS --- | ||||
| const CellRenderer = ({ partido, mode }: { partido?: RankingPartido, mode: DisplayMode }) => { | ||||
|     if (!partido) { | ||||
|         return <span>-</span>; | ||||
|     } | ||||
|  | ||||
|     switch (mode) { | ||||
|         case 'votos': | ||||
|             return <span>{formatVotos(partido.votos)}</span>; | ||||
|         case 'ambos': | ||||
|             return ( | ||||
|                 <div className="cell-ambos"> | ||||
|                     <span>{formatVotos(partido.votos)}</span> | ||||
|                     <small>{formatPercent(partido.porcentaje)}</small> | ||||
|                 </div> | ||||
|             ); | ||||
|         case 'porcentaje': | ||||
|         default: | ||||
|             return <span>{formatPercent(partido.porcentaje)}</span>; | ||||
|     } | ||||
| }; | ||||
|  | ||||
|  | ||||
| export const ResultadosRankingMunicipioWidget = () => { | ||||
|     const [secciones, setSecciones] = useState<MunicipioSimple[]>([]); | ||||
|     const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|     const [displayMode, setDisplayMode] = useState<DisplayOption>(displayModeOptions[0]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchSecciones = async () => { | ||||
|             const seccionesData = await getSeccionesElectorales(); | ||||
|             if (seccionesData && seccionesData.length > 0) { | ||||
|                 const orden = new Map([ | ||||
|                     ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|                     ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|                 ]); | ||||
|                 const getOrden = (nombre: string) => { | ||||
|                     const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|                     return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|                 }; | ||||
|                 seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|                 setSecciones(seccionesData); | ||||
|                 if (!selectedSeccion) { | ||||
|                     setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre }); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|         fetchSecciones(); | ||||
|     }, [selectedSeccion]); | ||||
|  | ||||
|     const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]); | ||||
|  | ||||
|     const { data: rankingData, isLoading } = useQuery<ApiResponseRankingMunicipio>({ | ||||
|         queryKey: ['rankingMunicipiosPorSeccion', selectedSeccion?.value], | ||||
|         queryFn: () => getRankingMunicipiosPorSeccion(selectedSeccion!.value), | ||||
|         enabled: !!selectedSeccion, | ||||
|     }); | ||||
|  | ||||
|     return ( | ||||
|         <div className="tabla-resultados-widget"> | ||||
|             <div className="tabla-header"> | ||||
|                 <h3>Resultados por Municipio</h3> | ||||
|                 <div className="header-filters"> | ||||
|                     <Select | ||||
|                         options={displayModeOptions} | ||||
|                         value={displayMode} | ||||
|                         onChange={(option) => setDisplayMode(option as DisplayOption)} | ||||
|                         styles={customSelectStyles} | ||||
|                         isSearchable={false} | ||||
|                     /> | ||||
|                     <Select | ||||
|                         options={seccionOptions} | ||||
|                         value={selectedSeccion} | ||||
|                         onChange={(option) => setSelectedSeccion(option)} | ||||
|                         isLoading={secciones.length === 0} | ||||
|                         styles={customSelectStyles} | ||||
|                         isSearchable={false} | ||||
|                     /> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div className="tabla-container"> | ||||
|                 {isLoading ? <p>Cargando...</p> : !rankingData || rankingData.categorias.length === 0 ? <p>No hay datos.</p> : ( | ||||
|                     <table> | ||||
|                         <thead> | ||||
|                             {/* --- Fila 1: Nombres de Categorías --- */} | ||||
|                             <tr> | ||||
|                                 <th rowSpan={3} className="sticky-col municipio-header">Municipio</th> | ||||
|                                 {rankingData.categorias.map(cat => ( | ||||
|                                     <th key={cat.id} colSpan={4} className="categoria-header"> | ||||
|                                         {cat.nombre} | ||||
|                                     </th> | ||||
|                                 ))} | ||||
|                             </tr> | ||||
|                             {/* --- Fila 2: Puestos --- */} | ||||
|                             <tr> | ||||
|                                 {rankingData.categorias.flatMap(cat => [ | ||||
|                                     <th key={`${cat.id}-p1`} colSpan={2} className="puesto-header">1° Puesto</th>, | ||||
|                                     <th key={`${cat.id}-p2`} colSpan={2} className="puesto-header category-divider-header">2° Puesto</th> | ||||
|                                 ])} | ||||
|                             </tr> | ||||
|                             {/* --- Fila 3: Sub-cabeceras (Partido y %) --- */} | ||||
|                             <tr> | ||||
|                                 {rankingData.categorias.flatMap(cat => [ | ||||
|                                     <th key={`${cat.id}-p1-partido`} className="sub-header">Partido</th>, | ||||
|                                     <th key={`${cat.id}-p1-porc`} className="sub-header">%</th>, | ||||
|                                     <th key={`${cat.id}-p2-partido`} className="sub-header category-divider-header">Partido</th>, | ||||
|                                     <th key={`${cat.id}-p2-porc`} className="sub-header">%</th> | ||||
|                                 ])} | ||||
|                             </tr> | ||||
|                         </thead> | ||||
|                         <tbody> | ||||
|                             {rankingData.resultados.map(municipio => ( | ||||
|                                 <tr key={municipio.municipioId}> | ||||
|                                     <td className="sticky-col">{municipio.municipioNombre}</td> | ||||
|                                     {rankingData.categorias.flatMap(cat => { | ||||
|                                         const resCategoria = municipio.resultadosPorCategoria[cat.id]; | ||||
|                                         const primerPuesto = resCategoria?.ranking[0]; | ||||
|                                         const segundoPuesto = resCategoria?.ranking[1]; | ||||
|  | ||||
|                                         return [ | ||||
|                                             // --- Celdas para el 1° Puesto --- | ||||
|                                             <td key={`${municipio.municipioId}-${cat.id}-p1-partido`} className="cell-partido"> | ||||
|                                                 {primerPuesto?.nombreCorto || '-'} | ||||
|                                             </td>, | ||||
|                                             <td key={`${municipio.municipioId}-${cat.id}-p1-porc`} className="cell-porcentaje"> | ||||
|                                                 {primerPuesto ? <CellRenderer partido={primerPuesto} mode={displayMode.value} /> : '-'} | ||||
|                                             </td>, | ||||
|  | ||||
|                                             // --- Celdas para el 2° Puesto --- | ||||
|                                             <td key={`${municipio.municipioId}-${cat.id}-p2-partido`} className="cell-partido category-divider"> | ||||
|                                                 {segundoPuesto?.nombreCorto || '-'} | ||||
|                                             </td>, | ||||
|                                             <td key={`${municipio.municipioId}-${cat.id}-p2-porc`} className="cell-porcentaje"> | ||||
|                                                 {segundoPuesto ? <CellRenderer partido={segundoPuesto} mode={displayMode.value} /> : '-'} | ||||
|                                             </td> | ||||
|                                         ]; | ||||
|                                     })} | ||||
|                                 </tr> | ||||
|                             ))} | ||||
|                         </tbody> | ||||
|                     </table> | ||||
|                 )} | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,109 @@ | ||||
| // src/features/legislativas/provinciales/ResultadosTablaDetalladaWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getSeccionesElectorales, getResultadosTablaDetallada } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ApiResponseTablaDetallada } from '../../../types/types'; | ||||
| import './ResultadosTablaSeccionWidget.css'; | ||||
|  | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '200px', border: '1px solid #ced4da', boxShadow: 'none', '&:hover': { borderColor: '#86b7fe' } }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 10 }), | ||||
| }; | ||||
|  | ||||
| const formatPercent = (porcentaje: number) => `${porcentaje.toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| export const ResultadosTablaDetalladaWidget = () => { | ||||
|   const [secciones, setSecciones] = useState<MunicipioSimple[]>([]); | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const fetchSecciones = async () => { | ||||
|       const seccionesData = await getSeccionesElectorales(); | ||||
|       if (seccionesData && seccionesData.length > 0) { | ||||
|         const orden = new Map([ | ||||
|           ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|           ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|         ]); | ||||
|         const getOrden = (nombre: string) => { | ||||
|           const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|           return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|         }; | ||||
|         seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|         setSecciones(seccionesData); | ||||
|         if (!selectedSeccion) { | ||||
|           setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre }); | ||||
|         } | ||||
|       } | ||||
|     }; | ||||
|     fetchSecciones(); | ||||
|   }, [selectedSeccion]); | ||||
|  | ||||
|   const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]); | ||||
|  | ||||
|   const { data: tablaData, isLoading } = useQuery<ApiResponseTablaDetallada>({ | ||||
|     queryKey: ['resultadosTablaDetallada', selectedSeccion?.value], | ||||
|     queryFn: () => getResultadosTablaDetallada(selectedSeccion!.value), | ||||
|     enabled: !!selectedSeccion, | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <div className="tabla-resultados-widget"> | ||||
|       <div className="tabla-header"> | ||||
|         <h3>Resultados por Sección Electoral</h3> | ||||
|         <Select | ||||
|           options={seccionOptions} | ||||
|           value={selectedSeccion} | ||||
|           onChange={(option) => setSelectedSeccion(option)} | ||||
|           isLoading={secciones.length === 0} | ||||
|           styles={customSelectStyles} | ||||
|           isSearchable={false} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div className="tabla-container"> | ||||
|         {isLoading ? <p>Cargando...</p> : !tablaData || tablaData.categorias.length === 0 ? <p>No hay datos disponibles.</p> : ( | ||||
|           <table> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th rowSpan={2} className="sticky-col municipio-header">Municipio</th> | ||||
|                 {tablaData.categorias.map(cat => ( | ||||
|                   <th key={cat.id} colSpan={tablaData.partidosPorCategoria[cat.id]?.length || 1} className="categoria-header"> | ||||
|                     {cat.nombre} | ||||
|                   </th> | ||||
|                 ))} | ||||
|               </tr> | ||||
|               <tr> | ||||
|                 {tablaData.categorias.flatMap(cat => | ||||
|                   (tablaData.partidosPorCategoria[cat.id] || []).map(partido => ( | ||||
|                     <th key={`header-${cat.id}-${partido.id}`} className="partido-header"> | ||||
|                       <span>{partido.puesto}° {partido.nombre} - </span> | ||||
|                       <span className="porcentaje-total">{formatPercent(partido.porcentajeTotalSeccion)}</span> | ||||
|                     </th> | ||||
|                   )) | ||||
|                 )} | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               {tablaData.resultadosPorMunicipio.map(municipio => ( | ||||
|                 <tr key={municipio.municipioId}> | ||||
|                   <td className="sticky-col">{municipio.municipioNombre}</td> | ||||
|                   {tablaData.categorias.flatMap(cat => | ||||
|                     (tablaData.partidosPorCategoria[cat.id] || []).map(partido => { | ||||
|                       const porcentaje = municipio.celdas[cat.id]?.[partido.id]; | ||||
|                       return ( | ||||
|                         <td key={`${municipio.municipioId}-${cat.id}-${partido.id}`}> | ||||
|                           {typeof porcentaje === 'number' ? formatPercent(porcentaje) : '-'} | ||||
|                         </td> | ||||
|                       ); | ||||
|                     }) | ||||
|                   )} | ||||
|                 </tr> | ||||
|               ))} | ||||
|             </tbody> | ||||
|           </table> | ||||
|         )} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,213 @@ | ||||
| /* src/features/legislativas/provinciales/ResultadosTablaSeccionWidget.css */ | ||||
|  | ||||
| .tabla-resultados-widget { | ||||
|     font-family: 'Roboto', sans-serif; | ||||
|     background-color: #ffffff; | ||||
|     border-radius: 8px; | ||||
|     padding: 16px 20px; | ||||
|     box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05); | ||||
|     border: 1px solid #e9ecef; | ||||
|     color: #333; | ||||
|     max-width: 100%; | ||||
|     margin: auto; | ||||
| } | ||||
|  | ||||
| .tabla-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     margin-bottom: 16px; | ||||
|     padding-bottom: 16px; | ||||
|     border-bottom: 1px solid #dee2e6; | ||||
| } | ||||
|  | ||||
| .header-filters { | ||||
|     display: flex; | ||||
|     gap: 1rem; | ||||
| } | ||||
|  | ||||
| .tabla-header h3 { | ||||
|     margin: 0; | ||||
|     font-size: 1.25rem; | ||||
|     color: #212529; | ||||
| } | ||||
|  | ||||
| .tabla-container { | ||||
|     width: 100%; | ||||
|     overflow-x: auto; | ||||
|     border: 1px solid #dee2e6; | ||||
|     border-radius: 6px; | ||||
| } | ||||
|  | ||||
| .tabla-container table { | ||||
|     width: 100%; | ||||
|     border-collapse: collapse; | ||||
|     font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| /* --- CABECERAS (THEAD) --- */ | ||||
| .tabla-container thead th { | ||||
|     background-color: #f8f9fa; | ||||
|     font-weight: 600; | ||||
|     color: #495057; | ||||
|     padding: 8px 10px; | ||||
|     text-align: center; | ||||
|     text-transform: uppercase; | ||||
|     font-size: 0.75rem; | ||||
|     letter-spacing: 0.5px; | ||||
|     white-space: nowrap; | ||||
|     vertical-align: middle; | ||||
|     border-right: 1px solid #dee2e6; | ||||
| } | ||||
| .tabla-container thead th:last-child { | ||||
|     border-right: none; | ||||
| } | ||||
|  | ||||
| /* Fila 1: Categorías (SENADORES, CONCEJALES) */ | ||||
| .tabla-container th.categoria-header { | ||||
|     border-bottom: 1px solid #adb5bd; | ||||
|     border-right: 2px solid #adb5bd; | ||||
|     font-size: 0.8rem; | ||||
| } | ||||
|  | ||||
| /* Fila 2: Puestos (1° Puesto, 2° Puesto) */ | ||||
| .tabla-container th.puesto-header { | ||||
|     border-bottom: 1px solid #adb5bd; | ||||
|     border-right: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Fila 3: Sub-cabeceras (Partido, %) */ | ||||
| .tabla-container th.sub-header { | ||||
|     font-weight: 500; | ||||
|     font-size: 0.7rem; | ||||
|     color: #6c757d; | ||||
| } | ||||
|  | ||||
| /* --- CUERPO DE LA TABLA (TBODY) --- */ | ||||
| .tabla-container tbody td { | ||||
|     padding: 10px 12px; | ||||
|     border-bottom: 1px solid #e9ecef; | ||||
|     white-space: nowrap; | ||||
|     vertical-align: middle; | ||||
| } | ||||
| .tabla-container tbody tr:last-child td { | ||||
|     border-bottom: none; | ||||
| } | ||||
| .tabla-container tbody tr:nth-of-type(even) { | ||||
|     background-color: #f8f9fa; | ||||
| } | ||||
| .tabla-container tbody tr:hover { | ||||
|     background-color: #e2e6ea; | ||||
| } | ||||
|  | ||||
| /* Celdas específicas */ | ||||
| .tabla-container .cell-partido { | ||||
|     text-align: left; | ||||
|     font-size: 0.85rem; | ||||
|     padding-left: 15px; /* Añade un poco de espacio a la izquierda */ | ||||
|     border-right: 1px solid #e9ecef; /* Línea fina entre partido y % */ | ||||
| } | ||||
| .tabla-container .cell-porcentaje { | ||||
|     text-align: right; | ||||
|     font-family: 'Roboto Mono', 'Courier New', monospace; | ||||
|     font-weight: 500; | ||||
|     padding-right: 15px; /* Añade un poco de espacio a la derecha */ | ||||
| } | ||||
|  | ||||
| .tabla-container th.sub-header-init { | ||||
|     border-right: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Líneas divisorias entre categorías */ | ||||
| .tabla-container .category-divider-header { | ||||
|     border-left: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| .tabla-container .category-divider { | ||||
|     border-left: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Columna fija de Municipio */ | ||||
| .tabla-container th.sticky-col, | ||||
| .tabla-container td.sticky-col { | ||||
|     position: sticky; | ||||
|     left: 0; | ||||
|     background-color: #ffffff; | ||||
|     z-index: 1; | ||||
|     border-right: 2px solid #adb5bd; /* Línea divisoria gruesa para la columna fija */ | ||||
| } | ||||
| .tabla-container thead th.sticky-col { | ||||
|     background-color: #f8f9fa; | ||||
|     z-index: 2; | ||||
| } | ||||
| .tabla-container tbody tr:nth-of-type(even) td.sticky-col { | ||||
|     background-color: #f8f9fa; | ||||
| } | ||||
|  | ||||
| .tabla-container tbody tr:hover { | ||||
|     background-color: #e9ecef; | ||||
| } | ||||
|  | ||||
| /* Primera columna (Municipios) */ | ||||
| .tabla-container td:first-child { | ||||
|     font-weight: 500; | ||||
|     color: #212529; | ||||
|     text-align: left; | ||||
|     border-left: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Columnas de porcentajes */ | ||||
| .tabla-container td:not(:first-child) { | ||||
|     text-align: left; /* Opcional: puedes poner 'center' si prefieres */ | ||||
|     font-size: 0.85rem; /* Un poco más pequeño para que entre bien */ | ||||
| } | ||||
|  | ||||
| /* Línea divisoria vertical para las celdas de datos */ | ||||
| .tabla-container tbody td.category-divider { | ||||
|     border-right: 1px solid #ced4da; | ||||
|     border-left: 2px solid #adb5bd; | ||||
| } | ||||
|  | ||||
| /* Estilos para la columna fija (Sticky) */ | ||||
| .tabla-container th.sticky-col, | ||||
| .tabla-container td.sticky-col { | ||||
|     position: sticky; | ||||
|     left: 0; | ||||
|     background-color: #ffffff; /* Fondo blanco para que tape el contenido */ | ||||
|     z-index: 1; | ||||
| } | ||||
| .tabla-container thead th.sticky-col { | ||||
|     background-color: #f8f9fa; /* Mismo color que el resto de la cabecera */ | ||||
|     z-index: 2; | ||||
| } | ||||
| .tabla-container tbody tr:nth-of-type(even) td.sticky-col { | ||||
|     background-color: #f8f9fa; /* Para que coincida con el fondo de la fila */ | ||||
| } | ||||
|  | ||||
| /* Contenedor principal de la celda para alinear contenido */ | ||||
| .cell-content { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: baseline; | ||||
| } | ||||
|  | ||||
| /* Nombre del partido dentro de la celda */ | ||||
| .cell-partido-nombre { | ||||
|     text-align: left; | ||||
|     font-size: 0.85rem; | ||||
|     padding-right: 1rem; | ||||
| } | ||||
|  | ||||
| /* Contenedor para la vista "ambos" (votos y porcentaje) */ | ||||
| .cell-ambos { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: flex-end; | ||||
|     text-align: right; | ||||
|     line-height: 1.2; | ||||
| } | ||||
|  | ||||
| .cell-ambos small { | ||||
|     font-size: 0.8em; | ||||
|     color: #6c757d; | ||||
| } | ||||
| @@ -0,0 +1,117 @@ | ||||
| // src/features/legislativas/provinciales/ResumenGeneralWidget.tsx | ||||
| import { useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { CategoriaResumen, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| export const ResumenGeneralWidget = () => { | ||||
|   const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ | ||||
|     queryKey: ['resumenProvincial'], | ||||
|     queryFn: getResumenProvincial, | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   const aggregatedData = useMemo(() => { | ||||
|     if (!categorias) return null; | ||||
|  | ||||
|     const legislativeCategories = categorias.filter(c => c.categoriaId === 5 || c.categoriaId === 6); | ||||
|     if (legislativeCategories.length === 0) return null; | ||||
|  | ||||
|     const partyMap = new Map<string, Omit<ResultadoTicker, 'porcentaje'>>(); | ||||
|      | ||||
|     legislativeCategories.forEach(category => { | ||||
|       category.resultados.forEach(party => { | ||||
|         const existing = partyMap.get(party.id); | ||||
|         if (existing) { | ||||
|           existing.votos += party.votos; | ||||
|         } else { | ||||
|           // Clonamos el objeto para no modificar el original | ||||
|           partyMap.set(party.id, { ...party });  | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     const resultsArray = Array.from(partyMap.values()); | ||||
|     const grandTotalVotes = resultsArray.reduce((sum, party) => sum + party.votos, 0); | ||||
|      | ||||
|     const finalResults: ResultadoTicker[] = resultsArray | ||||
|       .map(party => ({ | ||||
|         ...party, | ||||
|         porcentaje: grandTotalVotes > 0 ? (party.votos * 100 / grandTotalVotes) : 0, | ||||
|       })) | ||||
|       .sort((a, b) => b.votos - a.votos); | ||||
|        | ||||
|     const avgMesas = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0), 0) / legislativeCategories.length; | ||||
|     const avgParticipacion = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.participacionPorcentaje ?? 0), 0) / legislativeCategories.length; | ||||
|  | ||||
|     return { | ||||
|       resultados: finalResults, | ||||
|       estadoRecuento: { mesasTotalizadasPorcentaje: avgMesas, participacionPorcentaje: avgParticipacion } | ||||
|     }; | ||||
|   }, [categorias]); | ||||
|  | ||||
|   if (isLoading) return <div className="ticker-card loading" style={{ gridColumn: '1 / -1' }}>Cargando resumen general...</div>; | ||||
|   if (error || !aggregatedData) return <div className="ticker-card error" style={{ gridColumn: '1 / -1' }}>No hay datos para el resumen general.</div>; | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = aggregatedData.resultados; | ||||
|   if (aggregatedData.resultados.length > cantidadAMostrar) { | ||||
|     const topParties = aggregatedData.resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = aggregatedData.resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-general`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else { | ||||
|     displayResults = aggregatedData.resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card" style={{ gridColumn: '1 / -1' }}> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>RESUMEN LEGISLATIVO PROVINCIAL</h3> | ||||
|         <div className="ticker-stats"> | ||||
|           <span>Mesas (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.mesasTotalizadasPorcentaje)}</strong></span> | ||||
|           <span>Part (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.participacionPorcentaje)}</strong></span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,127 @@ | ||||
| // src/features/legislativas/provinciales/SenadoresPorSeccionWidget.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), | ||||
| }; | ||||
|  | ||||
| const CATEGORIA_ID = 5; // ID para Senadores | ||||
|  | ||||
| export const SenadoresPorSeccionWidget = () => { | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // Ahora usamos useQuery para obtener las secciones filtradas | ||||
|   const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({ | ||||
|     queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché | ||||
|     queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría | ||||
|   }); | ||||
|  | ||||
|   // useEffect para establecer la primera sección por defecto | ||||
|   useEffect(() => { | ||||
|     if (secciones.length > 0 && !selectedSeccion) { | ||||
|       // Ordenamos aquí solo para la selección inicial | ||||
|       const orden = new Map([ | ||||
|           ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|           ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|       ]); | ||||
|       const getOrden = (nombre: string) => { | ||||
|           const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|           return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|       }; | ||||
|       const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|        | ||||
|       setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre }); | ||||
|     } | ||||
|   }, [secciones, selectedSeccion]); | ||||
|  | ||||
|   const seccionOptions = useMemo(() => | ||||
|     secciones | ||||
|       .map(s => ({ value: s.id, label: s.nombre })) | ||||
|       .sort((a, b) => { // Mantenemos el orden en el dropdown | ||||
|           const orden = new Map([ | ||||
|               ['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3], | ||||
|               ['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7] | ||||
|           ]); | ||||
|           return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99); | ||||
|       }), | ||||
|   [secciones]); | ||||
|  | ||||
|   const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({ | ||||
|     queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedSeccion, | ||||
|   }); | ||||
|  | ||||
|   const resultados = data?.resultados || []; | ||||
|  | ||||
|   let displayResults: ResultadoTicker[] = resultados; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-senadores-${selectedSeccion?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>SENADORES POR SECCIÓN</h3> | ||||
|         <Select | ||||
|           options={seccionOptions} | ||||
|           value={selectedSeccion} | ||||
|           onChange={(option) => setSelectedSeccion(option)} | ||||
|           isLoading={isLoadingSecciones} | ||||
|           placeholder="Seleccionar sección..." | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>} | ||||
|         {!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,84 @@ | ||||
| // src/features/legislativas/provinciales/SenadoresTickerWidget.tsx | ||||
| import { useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenProvincial, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; | ||||
| import type { CategoriaResumen, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; // Reutilizamos los mismos estilos | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
| const CATEGORIA_ID = 5; // ID para Senadores | ||||
|  | ||||
| export const SenadoresTickerWidget = () => { | ||||
|   const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ | ||||
|     queryKey: ['resumenProvincial'], | ||||
|     queryFn: getResumenProvincial, | ||||
|     refetchInterval: 30000, | ||||
|   }); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|   }); | ||||
|  | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   // Usamos useMemo para encontrar los datos específicos de Senadores | ||||
|   const senadoresData = useMemo(() => { | ||||
|     return categorias?.find(c => c.categoriaId === CATEGORIA_ID); | ||||
|   }, [categorias]); | ||||
|  | ||||
|   if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>; | ||||
|   if (error || !senadoresData) return <div className="ticker-card error"><p>Datos de Senadores no disponibles.</p></div>; | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = senadoresData.resultados; | ||||
|   if (senadoresData.resultados.length > cantidadAMostrar) { | ||||
|     const topParties = senadoresData.resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = senadoresData.resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-senadores`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else { | ||||
|     displayResults = senadoresData.resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>RESUMEN DE {senadoresData.categoriaNombre}</h3> | ||||
|         <div className="ticker-stats"> | ||||
|           <span>Mesas: <strong>{formatPercent(senadoresData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span> | ||||
|           <span>Part: <strong>{formatPercent(senadoresData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,120 @@ | ||||
| // src/features/legislativas/provinciales/SenadoresWidget.tsx | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; // Importamos react-select | ||||
| import { getMunicipios, getResultadosPorMunicipio, getConfiguracionPublica, assetBaseUrl } from '../../../apiService'; // Usamos las funciones genéricas | ||||
| import type { MunicipioSimple, ResultadoTicker } from '../../../types/types'; | ||||
| import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; | ||||
| import './TickerWidget.css'; | ||||
|  | ||||
| const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| // Estilos para el selector, podemos moverlos a un archivo común más adelante | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 100 }), | ||||
| }; | ||||
|  | ||||
| // Constante para la categoría de este widget | ||||
| const CATEGORIA_ID = 5; // Senadores | ||||
|  | ||||
| export const SenadoresWidget = () => { | ||||
|   const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   const { data: configData } = useQuery({ | ||||
|     queryKey: ['configuracionPublica'], | ||||
|     queryFn: getConfiguracionPublica, | ||||
|     staleTime: 0, | ||||
|     refetchInterval: 180000, | ||||
|   }); | ||||
|  | ||||
|   // Usamos la clave de configuración del Ticker, ya que es para Senadores/Diputados | ||||
|   const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10); | ||||
|  | ||||
|   const { data: municipios = [], isLoading: isLoadingMunicipios } = useQuery<MunicipioSimple[]>({ | ||||
|     queryKey: ['municipios', CATEGORIA_ID], // Key única para la caché | ||||
|     queryFn: () => getMunicipios(), // Pasamos el ID de la categoría | ||||
|   }); | ||||
|  | ||||
|   // useEffect para establecer "ALBERTI" por defecto | ||||
|   useEffect(() => { | ||||
|     if (municipios.length > 0 && !selectedMunicipio) { | ||||
|       const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'ALBERTI'); | ||||
|       if (laPlata) { | ||||
|         setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre }); | ||||
|       } | ||||
|     } | ||||
|   }, [municipios, selectedMunicipio]); | ||||
|  | ||||
|   const municipioOptions = useMemo(() => | ||||
|     municipios | ||||
|       .map(m => ({ value: m.id, label: m.nombre })) | ||||
|       .sort((a, b) => a.label.localeCompare(b.label)), | ||||
|     [municipios]); | ||||
|  | ||||
|   const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({ | ||||
|     queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID], | ||||
|     queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID), | ||||
|     enabled: !!selectedMunicipio, | ||||
|   }); | ||||
|  | ||||
|   // Lógica para "Otros" | ||||
|   let displayResults: ResultadoTicker[] = resultados || []; | ||||
|   if (resultados && resultados.length > cantidadAMostrar) { | ||||
|     const topParties = resultados.slice(0, cantidadAMostrar - 1); | ||||
|     const otherParties = resultados.slice(cantidadAMostrar - 1); | ||||
|     const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0); | ||||
|     const otrosEntry: ResultadoTicker = { | ||||
|       id: `otros-senadores-${selectedMunicipio?.value}`, | ||||
|       nombre: 'Otros', | ||||
|       nombreCorto: 'Otros', | ||||
|       color: '#888888', | ||||
|       logoUrl: null, | ||||
|       votos: 0, | ||||
|       porcentaje: otrosPorcentaje, | ||||
|     }; | ||||
|     displayResults = [...topParties, otrosEntry]; | ||||
|   } else if (resultados) { | ||||
|     displayResults = resultados.slice(0, cantidadAMostrar); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="ticker-card"> | ||||
|       <div className="ticker-header"> | ||||
|         <h3>SENADORES POR MUNICIPIO</h3> | ||||
|         <Select | ||||
|           options={municipioOptions} | ||||
|           value={selectedMunicipio} | ||||
|           onChange={(option) => setSelectedMunicipio(option)} | ||||
|           isLoading={isLoadingMunicipios} | ||||
|           placeholder="Buscar municipio..." | ||||
|           isClearable | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="ticker-results"> | ||||
|         {(isLoadingMunicipios || (isLoadingResultados && selectedMunicipio)) && <p>Cargando...</p>} | ||||
|         {!selectedMunicipio && !isLoadingMunicipios && <p style={{ textAlign: 'center', color: '#666' }}>Seleccione un municipio.</p>} | ||||
|         {displayResults.map(partido => ( | ||||
|           <div key={partido.id} className="ticker-party"> | ||||
|             <div className="party-logo"> | ||||
|               <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={`Logo de ${partido.nombre}`} /> | ||||
|             </div> | ||||
|             <div className="party-details"> | ||||
|               <div className="party-info"> | ||||
|                 <span className="party-name">{partido.nombreCorto || partido.nombre}</span> | ||||
|                 <span className="party-percent">{formatPercent(partido.porcentaje)}</span> | ||||
|               </div> | ||||
|               <div className="party-bar-background"> | ||||
|                 <div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div> | ||||
|               </div> | ||||
|               <div className="party-candidate-name"> | ||||
|                 {partido.nombreCandidato} | ||||
|               </div> | ||||
|             </div> | ||||
|           </div> | ||||
|         ))} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,172 @@ | ||||
| /* src/features/legislativas/provinciales/TelegramaWidget.css */ | ||||
| .telegrama-container { | ||||
|   background-color: #ffffff; | ||||
|   border: 1px solid #e0e0e0; | ||||
|   box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|   padding: 1.5rem 2rem; | ||||
|   border-radius: 8px; | ||||
|   max-width: 800px; | ||||
|   margin: 20px auto; | ||||
|   font-family: "Public Sans", system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| .telegrama-container h4 { | ||||
|   margin-top: 0; | ||||
|   color: #212529; | ||||
|   font-size: 1.2em; | ||||
|   font-weight: 700; | ||||
|   margin-bottom: 1.5rem; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .filters-grid { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | ||||
|   gap: 1rem; | ||||
|   margin-bottom: 1.5rem; | ||||
| } | ||||
|  | ||||
| .filters-grid select { | ||||
|   width: 100%; | ||||
|   padding: 0.75rem; | ||||
|   font-size: 1em; | ||||
|   border: 1px solid #ced4da; | ||||
|   border-radius: 4px; | ||||
|   font-family: inherit; | ||||
|   background-color: #fff; | ||||
| } | ||||
|  | ||||
| .filters-grid select:disabled { | ||||
|   background-color: #e9ecef; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .search-form { | ||||
|   display: flex; | ||||
|   gap: 0.5rem; | ||||
|   margin-bottom: 1.5rem; | ||||
| } | ||||
|  | ||||
| .search-input { | ||||
|   flex-grow: 1; | ||||
|   padding: 0.75rem; | ||||
|   font-size: 1em; | ||||
|   border: 1px solid #ced4da; | ||||
|   border-radius: 4px; | ||||
|   font-family: inherit; | ||||
| } | ||||
|  | ||||
| .search-button { | ||||
|   padding: 0.75rem 1.5rem; | ||||
|   font-size: 1em; | ||||
|   font-weight: 500; | ||||
|   border: none; | ||||
|   border-radius: 4px; | ||||
|   background-color: var(--primary-accent-color); | ||||
|   color: white; | ||||
|   cursor: pointer; | ||||
|   transition: background-color 0.2s; | ||||
| } | ||||
|  | ||||
| .search-button:hover { | ||||
|   background-color: #0056b3; | ||||
| } | ||||
|  | ||||
| .search-button:disabled { | ||||
|   background-color: #6c757d; | ||||
|   cursor: not-allowed; | ||||
| } | ||||
|  | ||||
| .telegrama-viewer { | ||||
|   /* Se mantiene el min-height aquí para el estado inicial (antes de cargar el PDF) */ | ||||
|   min-height: 400px;  | ||||
|   display: flex; | ||||
|   align-items: flex-start; /* Se cambia a flex-start para alinear el contenido arriba */ | ||||
|   justify-content: center; | ||||
|   border-top: 1px solid #e9ecef; | ||||
|   padding-top: 1.5rem; | ||||
| } | ||||
|  | ||||
| .telegrama-viewer .message { | ||||
|   color: #6c757d; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| .telegrama-viewer .message.error { | ||||
|   color: #d62728; | ||||
| } | ||||
|  | ||||
| .telegrama-content { | ||||
|   display: flex; | ||||
|   gap: 2rem; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .telegrama-pdf-viewer { | ||||
|   flex: 1 1 100%; | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 4px; | ||||
|   background-color: #f8f9fa; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
|  | ||||
|   /* --- INICIO DE LA CORRECCIÓN CLAVE --- */ | ||||
|   /* Se elimina min-height para que el contenedor se ajuste a la altura del PDF */ | ||||
|   /* Se añade max-height para controlar PDFs muy largos y activar el scroll */ | ||||
|   max-height: 80vh;  | ||||
|   /* --- FIN DE LA CORRECCIÓN CLAVE --- */ | ||||
|    | ||||
|   overflow-x: hidden; | ||||
|   overflow-y: auto; | ||||
| } | ||||
|  | ||||
| .telegrama-pdf-viewer .react-pdf__Page { | ||||
|   width: 100%; | ||||
|   max-width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: center; | ||||
| } | ||||
|  | ||||
| .telegrama-pdf-viewer .react-pdf__Page__canvas { | ||||
|   max-width: 100%; | ||||
|   /*  | ||||
|     Se elimina max-height: 100% y se vuelve a height: auto !important | ||||
|     para asegurar que la proporción se base únicamente en el ancho. | ||||
|   */ | ||||
|   height: auto !important; | ||||
| } | ||||
|  | ||||
| .telegrama-metadata { | ||||
|   flex: 1 1 300px; | ||||
|   min-width: 250px; | ||||
| } | ||||
|  | ||||
| .telegrama-metadata h5 { | ||||
|   margin-top: 0; | ||||
|   font-size: 1.1em; | ||||
|   border-bottom: 1px solid #e9ecef; | ||||
|   padding-bottom: 0.5rem; | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
|  | ||||
| .meta-item { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   margin-bottom: 1rem; | ||||
|   font-size: 0.9em; | ||||
| } | ||||
|  | ||||
| .meta-item span { | ||||
|   color: #6c757d; | ||||
| } | ||||
|  | ||||
| .meta-item strong { | ||||
|   color: #212529; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| @media (max-width: 768px) { | ||||
|   .telegrama-content { | ||||
|     flex-direction: column; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,189 @@ | ||||
| // src/features/legislativas/provinciales/TelegramaWidget.tsx | ||||
| import { useState, useEffect, useMemo } from 'react'; | ||||
| import Select, { type FilterOptionOption } from 'react-select'; // <-- Importar react-select | ||||
| import { | ||||
|   getSecciones, | ||||
|   getMunicipiosPorSeccion, | ||||
|   getEstablecimientosPorMunicipio, // <-- Nueva función | ||||
|   getMesasPorEstablecimiento, | ||||
|   getTelegramaPorId, | ||||
|   assetBaseUrl | ||||
| } from '../../../apiService'; | ||||
| import type { TelegramaData, CatalogoItem } from '../../../types/types'; | ||||
| import './TelegramaWidget.css'; | ||||
|  | ||||
| import { pdfjs, Document, Page } from 'react-pdf'; | ||||
| import 'react-pdf/dist/Page/AnnotationLayer.css'; | ||||
| import 'react-pdf/dist/Page/TextLayer.css'; | ||||
|  | ||||
| pdfjs.GlobalWorkerOptions.workerSrc = `${assetBaseUrl}/pdf.worker.min.mjs`; | ||||
|  | ||||
| // Estilos para los selectores | ||||
| const customSelectStyles = { | ||||
|   control: (base: any) => ({ ...base, marginBottom: '1rem' }), | ||||
|   menu: (base: any) => ({ ...base, zIndex: 10 }), | ||||
| }; | ||||
|  | ||||
| // --- FUNCIÓN DE FILTRO "SMART SEARCH" --- | ||||
| const smartSearchFilter = ( | ||||
|   option: FilterOptionOption<{ label: string; value: string }>, | ||||
|   inputValue: string | ||||
| ) => { | ||||
|   // 1. Si no hay entrada de búsqueda, muestra todas las opciones. | ||||
|   if (!inputValue) { | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   // 2. Normalizamos tanto la etiqueta de la opción como la entrada del usuario: | ||||
|   //    - a minúsculas | ||||
|   //    - quitamos los acentos (si fuera necesario, aunque aquí no tanto) | ||||
|   const normalizedLabel = option.label.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
|   const normalizedInput = inputValue.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); | ||||
|  | ||||
|   // 3. Dividimos la entrada del usuario en palabras individuales. | ||||
|   const searchTerms = normalizedInput.split(' ').filter(term => term.length > 0); | ||||
|  | ||||
|   // 4. La opción es válida si CADA TÉRMINO de búsqueda está incluido en la etiqueta. | ||||
|   return searchTerms.every(term => normalizedLabel.includes(term)); | ||||
| }; | ||||
|  | ||||
| export const TelegramaWidget = () => { | ||||
|   // Estados para los datos de los dropdowns | ||||
|   const [secciones, setSecciones] = useState<CatalogoItem[]>([]); | ||||
|   const [municipios, setMunicipios] = useState<CatalogoItem[]>([]); | ||||
|   const [establecimientos, setEstablecimientos] = useState<CatalogoItem[]>([]); | ||||
|   const [mesas, setMesas] = useState<CatalogoItem[]>([]); | ||||
|  | ||||
|   // Estados para los valores seleccionados (adaptados para react-select) | ||||
|   const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null); | ||||
|   const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null); | ||||
|   const [selectedEstablecimiento, setSelectedEstablecimiento] = useState<{ value: string; label: string } | null>(null); | ||||
|   const [selectedMesa, setSelectedMesa] = useState<{ value: string; label: string } | null>(null); | ||||
|  | ||||
|   // Estados para la visualización del telegrama (sin cambios) | ||||
|   const [telegrama, setTelegrama] = useState<TelegramaData | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|  | ||||
|   // Cargar secciones iniciales y aplicar orden | ||||
|   useEffect(() => { | ||||
|     getSecciones().then(seccionesData => { | ||||
|       const orden = new Map([ | ||||
|         ['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3], | ||||
|         ['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7] | ||||
|       ]); | ||||
|       const getOrden = (nombre: string) => { | ||||
|         const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/); | ||||
|         return match ? orden.get(match[0]) ?? 99 : 99; | ||||
|       }; | ||||
|       seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre)); | ||||
|       setSecciones(seccionesData); | ||||
|     }); | ||||
|   }, []); | ||||
|  | ||||
|   // Cargar municipios cuando cambia la sección | ||||
|   useEffect(() => { | ||||
|     if (selectedSeccion) { | ||||
|       setMunicipios([]); setEstablecimientos([]); setMesas([]); | ||||
|       setSelectedMunicipio(null); setSelectedEstablecimiento(null); setSelectedMesa(null); | ||||
|       getMunicipiosPorSeccion(selectedSeccion.value).then(setMunicipios); | ||||
|     } | ||||
|   }, [selectedSeccion]); | ||||
|  | ||||
|   // Cargar establecimientos cuando cambia el municipio (SIN pasar por circuito) | ||||
|   useEffect(() => { | ||||
|     if (selectedMunicipio) { | ||||
|       setEstablecimientos([]); setMesas([]); | ||||
|       setSelectedEstablecimiento(null); setSelectedMesa(null); | ||||
|       getEstablecimientosPorMunicipio(selectedMunicipio.value).then(setEstablecimientos); | ||||
|     } | ||||
|   }, [selectedMunicipio]); | ||||
|  | ||||
|   // Cargar mesas cuando cambia el establecimiento | ||||
|   useEffect(() => { | ||||
|     if (selectedEstablecimiento) { | ||||
|       setMesas([]); | ||||
|       setSelectedMesa(null); | ||||
|       getMesasPorEstablecimiento(selectedEstablecimiento.value).then(setMesas); | ||||
|     } | ||||
|   }, [selectedEstablecimiento]); | ||||
|  | ||||
|   // Buscar el telegrama cuando se selecciona una mesa | ||||
|   useEffect(() => { | ||||
|     if (selectedMesa) { | ||||
|       setLoading(true); setError(null); setTelegrama(null); | ||||
|       getTelegramaPorId(selectedMesa.value) | ||||
|         .then(setTelegrama) | ||||
|         .catch(() => setError(`El telegrama para la mesa seleccionada, aún no se cargó en el sistema.`)) | ||||
|         .finally(() => setLoading(false)); | ||||
|     } | ||||
|   }, [selectedMesa]); | ||||
|  | ||||
|   // Formateo de opciones para react-select | ||||
|   const seccionOptions = useMemo(() => secciones.map(s => ({ value: s.id, label: s.nombre })), [secciones]); | ||||
|   const municipioOptions = useMemo(() => municipios.map(m => ({ value: m.id, label: m.nombre })), [municipios]); | ||||
|   const establecimientoOptions = useMemo(() => establecimientos.map(e => ({ value: e.id, label: e.nombre })), [establecimientos]); | ||||
|   const mesaOptions = useMemo(() => mesas.map(m => ({ value: m.id, label: m.nombre })), [mesas]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="telegrama-container"> | ||||
|       <h4>Consulta de Telegramas por Ubicación</h4> | ||||
|       <div className="filters-grid"> | ||||
|         <Select | ||||
|           options={seccionOptions} | ||||
|           value={selectedSeccion} | ||||
|           onChange={setSelectedSeccion} | ||||
|           placeholder="1. Sección..." | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|         <Select | ||||
|           options={municipioOptions} | ||||
|           value={selectedMunicipio} | ||||
|           onChange={setSelectedMunicipio} | ||||
|           placeholder="2. Municipio..." | ||||
|           isDisabled={!selectedSeccion} | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|         <Select | ||||
|           options={establecimientoOptions} | ||||
|           value={selectedEstablecimiento} | ||||
|           onChange={setSelectedEstablecimiento} | ||||
|           placeholder="3. Establecimiento..." | ||||
|           isDisabled={!selectedMunicipio} | ||||
|           styles={customSelectStyles} | ||||
|           filterOption={smartSearchFilter} | ||||
|           isSearchable // Habilitamos la búsqueda | ||||
|         /> | ||||
|         <Select | ||||
|           options={mesaOptions} | ||||
|           value={selectedMesa} | ||||
|           onChange={setSelectedMesa} | ||||
|           placeholder="4. Mesa..." | ||||
|           isDisabled={!selectedEstablecimiento} | ||||
|           styles={customSelectStyles} | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div className="telegrama-viewer"> | ||||
|         {loading && <div className="spinner"></div>} | ||||
|         {error && <p className="message error">{error}</p>} | ||||
|  | ||||
|         {telegrama && ( | ||||
|           <div className="telegrama-content"> | ||||
|             <div className="telegrama-pdf-viewer"> | ||||
|               <Document | ||||
|                 file={`data:application/pdf;base64,${telegrama.contenidoBase64}`} | ||||
|                 onLoadError={(error) => setError(`Error al cargar el PDF: ${error.message}`)} | ||||
|                 loading={<div className="spinner"></div>} | ||||
|               > | ||||
|                 <Page pageNumber={1} renderTextLayer={false} renderAnnotationLayer={false} /> | ||||
|               </Document> | ||||
|             </div> | ||||
|           </div> | ||||
|         )} | ||||
|  | ||||
|         {!loading && !telegrama && !error && <p className="message">Seleccione una mesa para visualizar el telegrama.</p>} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,156 @@ | ||||
| /* src/features/legislativas/provinciales/TickerWidget.css */ | ||||
|  | ||||
| /* --- Contenedor Principal del Widget --- */ | ||||
| .ticker-card { | ||||
|     background-color: #ffffff; | ||||
|     border: 1px solid #e0e0e0; | ||||
|     box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|     padding: 1rem; /* Usamos rem para un padding relativo */ | ||||
|     border-radius: 8px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|  | ||||
|     /* --- CAMBIO CLAVE: Establecemos un tamaño de fuente base --- */ | ||||
|     /* 1rem = al tamaño de fuente del contenedor padre. Si la página usa 16px, | ||||
|        el widget usará 16px como base. Si usa 14px, se adaptará a 14px. */ | ||||
|     font-size: 1rem;  | ||||
| } | ||||
|  | ||||
| /* --- Cabecera del Ticker --- */ | ||||
| .ticker-header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   border-bottom: 1px solid #e0e0e0; | ||||
|   padding-bottom: 0.8rem; | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
|  | ||||
| .ticker-header h3 { | ||||
|   margin: 0; | ||||
|   color: #212529; | ||||
|   /* El tamaño del título es 1.1 veces el tamaño base del widget */ | ||||
|   font-size: 1.1rem;  | ||||
|   font-weight: 700; | ||||
|   padding-right: 1rem; | ||||
| } | ||||
|  | ||||
| .ticker-stats { | ||||
|   display: flex; | ||||
|   gap: 1.25rem; /* 20px / 16px */ | ||||
|   font-size: 0.875rem; /* 14px / 16px */ | ||||
|   color: #555; | ||||
| } | ||||
|  | ||||
| .ticker-stats strong { | ||||
|   color: #0073e6; | ||||
|   font-size: 1.1em; /* 1.1 veces el tamaño de su padre (0.875rem) */ | ||||
| } | ||||
|  | ||||
|  | ||||
| /* --- Resultados (Grid de Partidos) --- */ | ||||
| .ticker-results { | ||||
|     display: grid; | ||||
|     /* Mantenemos minmax para la responsividad de las columnas */ | ||||
|     grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));  | ||||
|     gap: 1.25rem; /* 20px */ | ||||
| } | ||||
|  | ||||
| .ticker-party { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.8rem; /* 10px */ | ||||
| } | ||||
|  | ||||
| /* Logo del partido */ | ||||
| .party-logo { | ||||
|     flex-shrink: 0; | ||||
|     width: 65px; /* Ligeramente más pequeño para no ser tan dominante */ | ||||
|     height: 65px; | ||||
| } | ||||
| .party-logo img { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: contain; /* Usar 'contain' es más seguro para logos */ | ||||
|     border-radius: 4px; | ||||
|     border: 1px solid #ddd; | ||||
| } | ||||
|  | ||||
| /* Detalles (Nombre, Barra, Candidato) */ | ||||
| .party-details { | ||||
|     flex-grow: 1; | ||||
|     min-width: 0; | ||||
| } | ||||
|  | ||||
| .ticker-party .party-info { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: baseline; /* Alinea la base del texto */ | ||||
|   margin-bottom: 0.3rem; /* 5px */ | ||||
| } | ||||
|  | ||||
| .ticker-party .party-name { | ||||
|   font-weight: 800; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   padding-right: 0.6rem; | ||||
|   font-size: 0.9rem; | ||||
|   color: #343a40; | ||||
| } | ||||
|  | ||||
| .ticker-party .party-percent { | ||||
|   font-weight: 700; | ||||
|   font-size: 0.95rem; /* Un poco más grande para destacar */ | ||||
|   flex-shrink: 0; /* Evita que el porcentaje se comprima */ | ||||
| } | ||||
|  | ||||
| .party-bar-background { | ||||
|   background-color: #e9ecef; | ||||
|   border-radius: 4px; | ||||
|   height: 8px; /* Un poco más delgada */ | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .party-bar-foreground { | ||||
|   height: 100%; | ||||
|   border-radius: 4px; | ||||
|   transition: width 0.5s ease-in-out; | ||||
| } | ||||
|  | ||||
| .party-candidate-name { | ||||
|   font-size: 0.8rem; /* 12.8px / 16px */ | ||||
|   color: #555; | ||||
|   margin-top: 0.3rem; /* 4px */ | ||||
|   font-weight: 400; /* Ligeramente menos pesado */ | ||||
|   text-transform: uppercase; | ||||
|   letter-spacing: 0.5px; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* --- Media Query para Móviles --- */ | ||||
| @media (max-width: 600px) { | ||||
|   .ticker-header { | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     gap: 0.8rem; | ||||
|   } | ||||
|  | ||||
|   .ticker-header h3 { | ||||
|     font-size: 1.1rem; | ||||
|     padding-right: 0; | ||||
|   } | ||||
|    | ||||
|   .ticker-results { | ||||
|       /* En móvil, forzamos una sola columna */ | ||||
|       grid-template-columns: 1fr; | ||||
|   } | ||||
|  | ||||
|   .ticker-party .party-name, | ||||
|   .party-candidate-name { | ||||
|     white-space: normal; /* Permitimos que el texto se divida en varias líneas */ | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user