Feat Widgets Cards y Optimización de Consultas
This commit is contained in:
		| @@ -1,4 +1,6 @@ | ||||
| // src/features/legislativas/rovinciales/DevAppLegislativas.tsx | ||||
| import { ResultadosNacionalesCardsWidget } from './nacionales/ResultadosNacionalesCardsWidget'; | ||||
| import { CongresoNacionalWidget } from './nacionales/CongresoNacionalWidget'; | ||||
| import { PanelNacionalWidget } from './nacionales/PanelNacionalWidget'; | ||||
| import './DevAppStyle.css' | ||||
|  | ||||
| @@ -6,9 +8,8 @@ export const DevAppLegislativas = () => { | ||||
|     return ( | ||||
|         <div className="container"> | ||||
|             <h1>Visor de Widgets</h1> | ||||
|              | ||||
|             {/* Le pasamos el ID de la elección que queremos visualizar. | ||||
|                 Para tus datos de prueba provinciales, este ID es 1. */} | ||||
|             <ResultadosNacionalesCardsWidget eleccionId={2} /> | ||||
|             <CongresoNacionalWidget eleccionId={2} /> | ||||
|             <PanelNacionalWidget eleccionId={2} /> | ||||
|         </div> | ||||
|     ); | ||||
|   | ||||
| @@ -0,0 +1,162 @@ | ||||
| // src/features/legislativas/nacionales/CongresoNacionalWidget.tsx | ||||
| import { useState, Suspense, useMemo } from 'react'; | ||||
| import { useSuspenseQuery } from '@tanstack/react-query'; | ||||
| import { Tooltip } from 'react-tooltip'; | ||||
| import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout'; | ||||
| import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout'; | ||||
| import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService'; | ||||
| import '../provinciales/CongresoWidget.css'; | ||||
|  | ||||
| interface CongresoNacionalWidgetProps { | ||||
|   eleccionId: number; | ||||
| } | ||||
|  | ||||
| const formatTimestamp = (dateString: string) => { | ||||
|   if (!dateString) return '...'; | ||||
|   const date = new Date(dateString); | ||||
|   const day = String(date.getDate()).padStart(2, '0'); | ||||
|   const month = String(date.getMonth() + 1).padStart(2, '0'); | ||||
|   const year = date.getFullYear(); | ||||
|   const hours = String(date.getHours()).padStart(2, '0'); | ||||
|   const minutes = String(date.getMinutes()).padStart(2, '0'); | ||||
|   return `${day}/${month}/${year} ${hours}:${minutes}`; | ||||
| }; | ||||
|  | ||||
| const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => { | ||||
|   const [camaraActiva, setCamaraActiva] = useState<'diputados' | 'senadores'>('diputados'); | ||||
|   const [isHovering, setIsHovering] = useState(false); | ||||
|  | ||||
|   const { data } = useSuspenseQuery<ComposicionNacionalData>({ | ||||
|     queryKey: ['composicionNacional', eleccionId], | ||||
|     queryFn: () => getComposicionNacional(eleccionId), | ||||
|     refetchInterval: 30000, | ||||
|   }); | ||||
|  | ||||
|   const datosCamaraActual = data[camaraActiva]; | ||||
|  | ||||
|   const partidosOrdenados = useMemo(() => { | ||||
|     if (!datosCamaraActual?.partidos) return []; | ||||
|     const partidosACopiar = [...datosCamaraActual.partidos]; | ||||
|     partidosACopiar.sort((a, b) => { | ||||
|       const ordenA = camaraActiva === 'diputados' ? a.ordenDiputadosNacionales : a.ordenSenadoresNacionales; | ||||
|       const ordenB = camaraActiva === 'diputados' ? b.ordenDiputadosNacionales : b.ordenSenadoresNacionales; | ||||
|       return (ordenA ?? 999) - (ordenB ?? 999); | ||||
|     }); | ||||
|     return partidosACopiar; | ||||
|   }, [datosCamaraActual, camaraActiva]); | ||||
|  | ||||
|   const partyDataParaLayout = useMemo(() => { | ||||
|     if (camaraActiva === 'senadores') return partidosOrdenados; | ||||
|     if (!partidosOrdenados || !datosCamaraActual.presidenteBancada?.color) return partidosOrdenados; | ||||
|     const partidoPresidente = partidosOrdenados.find(p => p.color === datosCamaraActual.presidenteBancada!.color); | ||||
|     if (!partidoPresidente) return partidosOrdenados; | ||||
|  | ||||
|     const adjustedPartyData = JSON.parse(JSON.stringify(partidosOrdenados)); | ||||
|     const partidoAjustar = adjustedPartyData.find((p: PartidoComposicionNacional) => p.id === partidoPresidente.id); | ||||
|  | ||||
|     if (partidoAjustar) { | ||||
|       const tipoBanca = datosCamaraActual.presidenteBancada.tipoBanca; | ||||
|       if (tipoBanca === 'ganada' && partidoAjustar.bancasGanadas > 0) { | ||||
|         partidoAjustar.bancasGanadas -= 1; | ||||
|       } else if (tipoBanca === 'previa' && partidoAjustar.bancasFijos > 0) { | ||||
|         partidoAjustar.bancasFijos -= 1; | ||||
|       } else { | ||||
|         if (partidoAjustar.bancasGanadas > 0) { | ||||
|           partidoAjustar.bancasGanadas -= 1; | ||||
|         } else if (partidoAjustar.bancasFijos > 0) { | ||||
|           partidoAjustar.bancasFijos -= 1; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return adjustedPartyData; | ||||
|   }, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]); | ||||
|  | ||||
|   return ( | ||||
|     <div className="congreso-container"> | ||||
|       <div className="congreso-grafico"> | ||||
|         <div | ||||
|           className={`congreso-hemiciclo-wrapper ${isHovering ? 'is-hovering' : ''}`} | ||||
|           onMouseEnter={() => setIsHovering(true)} | ||||
|           onMouseLeave={() => setIsHovering(false)} | ||||
|         > | ||||
|           {camaraActiva === 'diputados' ? | ||||
|             <DiputadosNacionalesLayout | ||||
|               partyData={partyDataParaLayout} | ||||
|               presidenteBancada={datosCamaraActual.presidenteBancada || null} | ||||
|               size={700} | ||||
|             /> : | ||||
|             <SenadoresNacionalesLayout | ||||
|               partyData={partyDataParaLayout} | ||||
|               presidenteBancada={datosCamaraActual.presidenteBancada || null} | ||||
|               size={700} | ||||
|             /> | ||||
|           } | ||||
|         </div> | ||||
|         <div className="congreso-footer"> | ||||
|           <div className="footer-legend"> | ||||
|             <div className="footer-legend-item"> | ||||
|               {/* Usamos la nueva clase CSS para el círculo sólido */} | ||||
|               <span className="legend-icon legend-icon--solid"></span> | ||||
|               <span>Bancas en juego</span> | ||||
|             </div> | ||||
|             <div className="footer-legend-item"> | ||||
|               {/* Reemplazamos el SVG por un span con la nueva clase para el anillo */} | ||||
|               <span className="legend-icon legend-icon--ring"></span> | ||||
|               <span>Bancas previas</span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div className="footer-timestamp"> | ||||
|             Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)} | ||||
|           </div> | ||||
|         </div> | ||||
|       </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> | ||||
|         <div className="summary-metric"> | ||||
|           <span>Bancas en Juego</span> | ||||
|           <strong>{datosCamaraActual.bancasEnJuego}</strong> | ||||
|         </div> | ||||
|         <hr /> | ||||
|         <div className="partido-lista-container"> | ||||
|           <ul className="partido-lista"> | ||||
|             {partidosOrdenados | ||||
|               .filter(p => p.bancasTotales > 0) | ||||
|               .map((partido: PartidoComposicionNacional) => ( | ||||
|                 <li key={partido.id}> | ||||
|                   <span className="partido-color-box" style={{ backgroundColor: partido.color || '#808080' }}></span> | ||||
|                   <span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span> | ||||
|                   <strong | ||||
|                     className="partido-bancas" | ||||
|                     title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`} | ||||
|                   > | ||||
|                     {partido.bancasTotales} | ||||
|                   </strong> | ||||
|                 </li> | ||||
|               ))} | ||||
|           </ul> | ||||
|         </div> | ||||
|       </div> | ||||
|       <Tooltip id="party-tooltip" /> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => { | ||||
|   return ( | ||||
|     <Suspense fallback={<div className="congreso-container loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}> | ||||
|       <WidgetContent eleccionId={eleccionId} /> | ||||
|     </Suspense> | ||||
|   ); | ||||
| }; | ||||
| @@ -1,10 +1,11 @@ | ||||
| /* src/features/legislativas/nacionales/PanelNaciona.css */ | ||||
| /* src/features/legislativas/nacionales/PanelNacional.css */ | ||||
| .panel-nacional-container { | ||||
|   font-family: 'Roboto', sans-serif; | ||||
|   max-width: 1200px; | ||||
|   margin: auto; | ||||
|   border: 1px solid #e0e0e0; | ||||
|   border-radius: 8px; | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .panel-header { | ||||
| @@ -491,13 +492,11 @@ | ||||
| /* --- NUEVOS ESTILOS PARA EL TOGGLE MÓVIL --- */ | ||||
| .mobile-view-toggle { | ||||
|   display: none; | ||||
|   /* Oculto por defecto */ | ||||
|   position: fixed; | ||||
|   bottom: 20px; | ||||
|   position: absolute; /* <-- CAMBIO: De 'fixed' a 'absolute' */ | ||||
|   bottom: 10px; /* <-- AJUSTE: Menos espacio desde abajo */ | ||||
|   left: 50%; | ||||
|   transform: translateX(-50%); | ||||
|   z-index: 100; | ||||
|  | ||||
|   background-color: rgba(255, 255, 255, 0.9); | ||||
|   border-radius: 30px; | ||||
|   padding: 5px; | ||||
|   | ||||
| @@ -0,0 +1,259 @@ | ||||
| /* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.css */ | ||||
|  | ||||
| /* --- Variables de Diseño --- */ | ||||
| :root { | ||||
|     --card-border-color: #e0e0e0; | ||||
|     --card-bg-color: #ffffff; | ||||
|     --card-header-bg-color: #f8f9fa; | ||||
|     --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); | ||||
|     --text-primary: #212529; | ||||
|     --text-secondary: #6c757d; | ||||
|     --font-family: "Public Sans", system-ui, sans-serif; | ||||
|     --primary-accent-color: #007bff; | ||||
| } | ||||
|  | ||||
| /* --- Contenedor Principal del Widget --- */ | ||||
| .cards-widget-container { | ||||
|     font-family: var(--font-family); | ||||
|     width: 100%; | ||||
|     max-width: 1200px; | ||||
|     margin: 2rem auto; | ||||
| } | ||||
|  | ||||
| .cards-widget-container h2 { | ||||
|     font-size: 1.75rem; | ||||
|     color: var(--text-primary); | ||||
|     margin-bottom: 1.5rem; | ||||
|     padding-bottom: 0.5rem; | ||||
|     border-bottom: 1px solid var(--card-border-color); | ||||
| } | ||||
|  | ||||
| /* --- Grilla de Tarjetas --- */ | ||||
| .cards-grid { | ||||
|     display: grid; | ||||
|     /* Crea columnas flexibles que se ajustan al espacio disponible */ | ||||
|     grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); | ||||
|     gap: 1.5rem; | ||||
| } | ||||
|  | ||||
| /* --- Tarjeta Individual --- */ | ||||
| .provincia-card { | ||||
|     background-color: var(--card-bg-color); | ||||
|     border: 1px solid var(--card-border-color); | ||||
|     border-radius: 8px; | ||||
|     box-shadow: var(--card-shadow); | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     overflow: hidden; /* Asegura que los bordes redondeados se apliquen al contenido */ | ||||
| } | ||||
|  | ||||
| /* --- Cabecera de la Tarjeta --- */ | ||||
| .card-header { | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     background-color: var(--card-header-bg-color); | ||||
|     padding: 0.75rem 1rem; | ||||
|     border-bottom: 1px solid var(--card-border-color); | ||||
| } | ||||
|  | ||||
| .header-info h3 { | ||||
|     margin: 0; | ||||
|     font-size: 1.2rem; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .header-info span { | ||||
|     font-size: 0.8rem; | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .header-map { | ||||
|     width: 90px; | ||||
|     height: 90px; | ||||
|     flex-shrink: 0; | ||||
|     border-radius: 4px; | ||||
|     overflow: hidden; | ||||
|     background-color: #e9ecef; | ||||
|     padding: 0.25rem; | ||||
|     box-sizing: border-box; /* Para que el padding no aumente el tamaño total */ | ||||
| } | ||||
|  | ||||
| /* Contenedor del SVG para asegurar que se ajuste al espacio */ | ||||
| .map-svg-container, .map-placeholder { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
| } | ||||
|  | ||||
| /* Estilo para el SVG renderizado */ | ||||
| .map-svg-container svg { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     object-fit: contain; /* Asegura que el mapa no se deforme */ | ||||
| } | ||||
|  | ||||
| /* Placeholder para cuando el mapa no carga */ | ||||
| .map-placeholder.error { | ||||
|     background-color: #f8d7da; /* Un color de fondo rojizo para indicar un error */ | ||||
| } | ||||
|  | ||||
| /* --- Cuerpo de la Tarjeta --- */ | ||||
| .card-body { | ||||
|     padding: 0.5rem 1rem; | ||||
| } | ||||
|  | ||||
| .candidato-row { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     gap: 0.75rem; | ||||
|     padding: 0.75rem 0; | ||||
|     border-bottom: 1px solid #f0f0f0; | ||||
| } | ||||
|  | ||||
| .candidato-row:last-child { | ||||
|     border-bottom: none; | ||||
| } | ||||
|  | ||||
| .candidato-foto { | ||||
|     width: 45px; | ||||
|     height: 45px; | ||||
|     border-radius: 50%; | ||||
|     object-fit: cover; | ||||
|     flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .candidato-data { | ||||
|     flex-grow: 1; | ||||
|     min-width: 0; /* Permite que el texto se trunque si es necesario */ | ||||
|     margin-right: 0.5rem; | ||||
| } | ||||
|  | ||||
| .candidato-nombre { | ||||
|     font-weight: 700; | ||||
|     font-size: 0.95rem; | ||||
|     color: var(--text-primary); | ||||
|     display: block; | ||||
| } | ||||
|  | ||||
| .candidato-partido { | ||||
|     font-size: 0.75rem; | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
|     display: block; | ||||
|     margin-bottom: 0.3rem; | ||||
| } | ||||
|  | ||||
| .progress-bar-container { | ||||
|     height: 6px; | ||||
|     background-color: #e9ecef; | ||||
|     border-radius: 3px; | ||||
|     overflow: hidden; | ||||
| } | ||||
|  | ||||
| .progress-bar { | ||||
|     height: 100%; | ||||
|     border-radius: 3px; | ||||
|     transition: width 0.5s ease-out; | ||||
| } | ||||
|  | ||||
| .candidato-stats { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: flex-end; | ||||
|     text-align: right; | ||||
|     flex-shrink: 0; | ||||
|     padding-left: 0.5rem; | ||||
| } | ||||
|  | ||||
| .stats-percent { | ||||
|     font-weight: 700; | ||||
|     font-size: 1.1rem; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| .stats-votos { | ||||
|     font-size: 0.8rem; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .stats-bancas { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     flex-shrink: 0; | ||||
|     border: 1px solid var(--card-border-color); | ||||
|     border-radius: 6px; | ||||
|     padding: 0.25rem 0.5rem; | ||||
|     margin-left: 0.75rem; | ||||
|     font-weight: 700; | ||||
|     font-size: 1.2rem; | ||||
|     color: var(--primary-accent-color); | ||||
|     min-width: 50px; | ||||
| } | ||||
|  | ||||
| .stats-bancas span { | ||||
|     font-size: 0.65rem; | ||||
|     font-weight: 500; | ||||
|     color: var(--text-secondary); | ||||
|     text-transform: uppercase; | ||||
|     margin-top: -4px; | ||||
| } | ||||
|  | ||||
|  | ||||
| /* --- Pie de la Tarjeta --- */ | ||||
| .card-footer { | ||||
|     display: grid; | ||||
|     grid-template-columns: repeat(3, 1fr); | ||||
|     background-color: var(--card-header-bg-color); | ||||
|     border-top: 1px solid var(--card-border-color); | ||||
|     padding: 0.75rem 0; | ||||
|     text-align: center; | ||||
| } | ||||
|  | ||||
| .card-footer div { | ||||
|     border-right: 1px solid var(--card-border-color); | ||||
| } | ||||
|  | ||||
| .card-footer div:last-child { | ||||
|     border-right: none; | ||||
| } | ||||
|  | ||||
| .card-footer span { | ||||
|     display: block; | ||||
|     font-size: 0.75rem; | ||||
|     color: var(--text-secondary); | ||||
| } | ||||
|  | ||||
| .card-footer strong { | ||||
|     font-size: 1rem; | ||||
|     font-weight: 700; | ||||
|     color: var(--text-primary); | ||||
| } | ||||
|  | ||||
| /* --- Media Query para Móvil --- */ | ||||
| @media (max-width: 480px) { | ||||
|     .cards-grid { | ||||
|         /* En pantallas muy pequeñas, forzamos una sola columna */ | ||||
|         grid-template-columns: 1fr; | ||||
|     } | ||||
|  | ||||
|     .card-header { | ||||
|         padding: 0.5rem; | ||||
|     } | ||||
|      | ||||
|     .header-info h3 { | ||||
|         font-size: 1rem; | ||||
|     } | ||||
| } | ||||
|  | ||||
| /* --- NUEVOS ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */ | ||||
| .candidato-partido.main-title { | ||||
|     font-size: 0.95rem;      /* Hacemos la fuente más grande */ | ||||
|     font-weight: 700;        /* La ponemos en negrita, como el nombre del candidato */ | ||||
|     color: var(--text-primary); /* Usamos el color de texto principal */ | ||||
|     text-transform: none;    /* Quitamos el 'uppercase' para que se lea mejor */ | ||||
|     margin-bottom: 0.3rem; | ||||
| } | ||||
| @@ -0,0 +1,30 @@ | ||||
| // src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.tsx | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { getResumenPorProvincia } from '../../../apiService'; | ||||
| import { ProvinciaCard } from './components/ProvinciaCard'; | ||||
| import './ResultadosNacionalesCardsWidget.css'; | ||||
|  | ||||
| interface Props { | ||||
|     eleccionId: number; | ||||
| } | ||||
|  | ||||
| export const ResultadosNacionalesCardsWidget = ({ eleccionId }: Props) => { | ||||
|     const { data, isLoading, error } = useQuery({ | ||||
|         queryKey: ['resumenPorProvincia', eleccionId], | ||||
|         queryFn: () => getResumenPorProvincia(eleccionId), | ||||
|     }); | ||||
|  | ||||
|     if (isLoading) return <div>Cargando resultados por provincia...</div>; | ||||
|     if (error) return <div>Error al cargar los datos.</div>; | ||||
|  | ||||
|     return ( | ||||
|         <section className="cards-widget-container"> | ||||
|             <h2>Resultados elecciones nacionales 2025</h2> | ||||
|             <div className="cards-grid"> | ||||
|                 {data?.map(provinciaData => ( | ||||
|                     <ProvinciaCard key={provinciaData.provinciaId} data={provinciaData} /> | ||||
|                 ))} | ||||
|             </div> | ||||
|         </section> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,64 @@ | ||||
| // src/features/legislativas/nacionales/components/MiniMapaSvg.tsx | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import axios from 'axios'; | ||||
| import { useMemo } from 'react'; | ||||
| import { assetBaseUrl } from '../../../../apiService'; | ||||
|  | ||||
| interface MiniMapaSvgProps { | ||||
|     provinciaNombre: string; | ||||
|     fillColor: string; | ||||
| } | ||||
|  | ||||
| // Función para normalizar el nombre de la provincia y que coincida con el nombre del archivo SVG | ||||
| const normalizarNombreParaUrl = (nombre: string) =>  | ||||
|     nombre | ||||
|         .toLowerCase() | ||||
|         .replace(/ /g, '_') // Reemplaza espacios con guiones bajos | ||||
|         .normalize("NFD")    // Descompone acentos para eliminarlos en el siguiente paso | ||||
|         .replace(/[\u0300-\u036f]/g, ""); // Elimina los acentos | ||||
|  | ||||
| export const MiniMapaSvg = ({ provinciaNombre, fillColor }: MiniMapaSvgProps) => { | ||||
|     const nombreNormalizado = normalizarNombreParaUrl(provinciaNombre); | ||||
|     // Asumimos que los SVGs están en /public/maps/provincias-svg/ | ||||
|     const mapFileUrl = `${assetBaseUrl}/maps/provincias-svg/${nombreNormalizado}.svg`; | ||||
|  | ||||
|     // Usamos React Query para fetchear el contenido del SVG como texto | ||||
|     const { data: svgContent, isLoading, isError } = useQuery<string>({ | ||||
|         queryKey: ['svgMapa', nombreNormalizado], | ||||
|         queryFn: async () => { | ||||
|             const response = await axios.get(mapFileUrl, { responseType: 'text' }); | ||||
|             return response.data; | ||||
|         }, | ||||
|         staleTime: Infinity, // Estos archivos son estáticos y no cambian | ||||
|         gcTime: Infinity, | ||||
|         retry: false, // No reintentar si el archivo no existe | ||||
|     }); | ||||
|  | ||||
|     // Usamos useMemo para modificar el SVG solo cuando el contenido o el color cambian | ||||
|     const modifiedSvg = useMemo(() => { | ||||
|         if (!svgContent) return ''; | ||||
|  | ||||
|         // Usamos una expresión regular para encontrar todas las etiquetas <path> | ||||
|         // y añadirles el atributo de relleno con el color del ganador. | ||||
|         // Esto sobrescribirá cualquier 'fill' que ya exista en la etiqueta. | ||||
|         return svgContent.replace(/<path/g, `<path fill="${fillColor}"`); | ||||
|     }, [svgContent, fillColor]); | ||||
|  | ||||
|     if (isLoading) { | ||||
|         return <div className="map-placeholder" />; | ||||
|     } | ||||
|  | ||||
|     if (isError || !modifiedSvg) { | ||||
|         // Muestra un placeholder si el SVG no se encontró o está vacío | ||||
|         return <div className="map-placeholder error" />; | ||||
|     } | ||||
|  | ||||
|     // Renderizamos el SVG modificado. dangerouslySetInnerHTML es seguro aquí | ||||
|     // porque el contenido proviene de nuestros propios archivos SVG estáticos. | ||||
|     return ( | ||||
|         <div  | ||||
|             className="map-svg-container"  | ||||
|             dangerouslySetInnerHTML={{ __html: modifiedSvg }}  | ||||
|         /> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,78 @@ | ||||
| // src/features/legislativas/nacionales/components/ProvinciaCard.tsx | ||||
| import type { ResumenProvincia } from '../../../../types/types'; | ||||
| import { MiniMapaSvg } from './MiniMapaSvg'; | ||||
| import { ImageWithFallback } from '../../../../components/common/ImageWithFallback'; | ||||
| import { assetBaseUrl } from '../../../../apiService'; | ||||
|  | ||||
| interface ProvinciaCardProps { | ||||
|     data: ResumenProvincia; | ||||
| } | ||||
|  | ||||
| const formatNumber = (num: number) => num.toLocaleString('es-AR'); | ||||
| const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`; | ||||
|  | ||||
| export const ProvinciaCard = ({ data }: ProvinciaCardProps) => { | ||||
|     // Determinamos el color del ganador para pasárselo al mapa. | ||||
|     // Si no hay ganador, usamos un color gris por defecto. | ||||
|     const colorGanador = data.resultados[0]?.color || '#d1d1d1'; | ||||
|  | ||||
|     return ( | ||||
|         <div className="provincia-card"> | ||||
|             <header className="card-header"> | ||||
|                 <div className="header-info"> | ||||
|                     <h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3> | ||||
|                     <span>DIPUTADOS NACIONALES</span> | ||||
|                 </div> | ||||
|                 <div className="header-map"> | ||||
|                     <MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} /> | ||||
|                 </div> | ||||
|             </header> | ||||
|             <div className="card-body"> | ||||
|                 {data.resultados.map(res => ( | ||||
|                     <div key={res.agrupacionId} className="candidato-row"> | ||||
|                         <ImageWithFallback src={res.fotoUrl ?? undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={res.nombreCandidato ?? res.nombreAgrupacion} className="candidato-foto" /> | ||||
|  | ||||
|                         <div className="candidato-data"> | ||||
|                             {res.nombreCandidato && ( | ||||
|                                 <span className="candidato-nombre">{res.nombreCandidato}</span> | ||||
|                             )} | ||||
|  | ||||
|                             <span className={`candidato-partido ${!res.nombreCandidato ? 'main-title' : ''}`}> | ||||
|                                 {res.nombreAgrupacion} | ||||
|                             </span> | ||||
|  | ||||
|                             <div className="progress-bar-container"> | ||||
|                                 <div className="progress-bar" style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} /> | ||||
|                             </div> | ||||
|                         </div> | ||||
|  | ||||
|                         <div className="candidato-stats"> | ||||
|                             <span className="stats-percent">{formatPercent(res.porcentaje)}</span> | ||||
|                             <span className="stats-votos">{formatNumber(res.votos)} votos</span> | ||||
|                         </div> | ||||
|                         <div className="stats-bancas"> | ||||
|                             +{res.bancasObtenidas} | ||||
|                             <span>Bancas</span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 ))} | ||||
|             </div> | ||||
|             <footer className="card-footer"> | ||||
|                 <div> | ||||
|                     <span>Participación</span> | ||||
|                     {/* Usamos los datos reales del estado de recuento */} | ||||
|                     <strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje ?? 0)}</strong> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                     <span>Mesas escrutadas</span> | ||||
|                     <strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                     <span>Votos totales</span> | ||||
|                     {/* Usamos el nuevo campo cantidadVotantes */} | ||||
|                     <strong>{formatNumber(data.estadoRecuento?.cantidadVotantes ?? 0)}</strong> | ||||
|                 </div> | ||||
|             </footer> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,27 +1,35 @@ | ||||
| /* src/features/legislativas/provinciales/CongresoWidget.css */ | ||||
| .congreso-container { | ||||
|   display: flex; | ||||
|   /* Se reduce ligeramente el espacio entre el gráfico y el panel */ | ||||
|   gap: 1rem; | ||||
|   gap: 1.5rem; | ||||
|   background-color: #ffffff; | ||||
|   border: 1px solid #e0e0e0; | ||||
|   box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); | ||||
|   padding: 1rem; | ||||
|   border-radius: 8px; | ||||
|   max-width: 800px; | ||||
|   max-width: 900px; | ||||
|   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%; | ||||
|   flex: 2;  | ||||
|   min-width: 300px; | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| .congreso-hemiciclo-wrapper { | ||||
|   flex-grow: 1; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .congreso-hemiciclo-wrapper.is-hovering .party-block:not(:hover) { | ||||
|   opacity: 0.4; | ||||
| } | ||||
|  | ||||
| .congreso-grafico svg { | ||||
| @@ -30,35 +38,139 @@ | ||||
|   animation: fadeIn 0.8s ease-in-out; | ||||
| } | ||||
|  | ||||
| @keyframes fadeIn { | ||||
|   from { | ||||
|     opacity: 0; | ||||
|     transform: scale(0.9); | ||||
|   } | ||||
| /* --- NUEVOS ESTILOS PARA EL FOOTER DEL GRÁFICO --- */ | ||||
| .congreso-footer { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   padding: 0.5rem 1rem 0 1rem; | ||||
|   margin-top: auto; /* Empuja el footer a la parte inferior del contenedor flex */ | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
|   border-top: 1px solid #eee; | ||||
| } | ||||
|  | ||||
|   to { | ||||
|     opacity: 1; | ||||
|     transform: scale(1); | ||||
|   } | ||||
| .footer-legend { | ||||
|   display: flex; | ||||
|   gap: 1.5rem; /* Espacio entre los items de la leyenda */ | ||||
| } | ||||
|  | ||||
| .footer-legend-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.5rem; /* Espacio entre el icono y el texto */ | ||||
| } | ||||
|  | ||||
| .footer-timestamp { | ||||
|   font-weight: 500; | ||||
| } | ||||
|  | ||||
| /* --- ESTILOS PARA HOVER --- */ | ||||
|  | ||||
| /* Estilo base para cada círculo de escaño */ | ||||
| .seat-circle { | ||||
|   transition: all 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| .party-block { | ||||
|   cursor: pointer; | ||||
|   transition: opacity 0.2s ease-in-out; | ||||
| } | ||||
|  | ||||
| .party-block:hover .seat-circle { | ||||
|   stroke: #333 !important; /* Borde oscuro para resaltar */ | ||||
|   stroke-width: 1.5px !important; | ||||
|   stroke-opacity: 1; | ||||
|   filter: brightness(1.1); | ||||
| } | ||||
|  | ||||
| /* CORRECCIÓN: El selector ahora apunta al wrapper correcto */ | ||||
| .congreso-hemiciclo-wrapper.is-hovering .party-block:not(:hover) { | ||||
|   opacity: 0.3; /* Hacemos el desvanecimiento más pronunciado */ | ||||
| } | ||||
| .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); } | ||||
| } | ||||
|  | ||||
| /* --- INICIO DE NUEVOS ESTILOS PARA EL FOOTER DEL GRÁFICO --- */ | ||||
| .congreso-footer { | ||||
|   width: 100%; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   padding: 0.75rem 0.5rem 0 0.5rem; | ||||
|   margin-top: auto; | ||||
|   font-size: 0.8em; | ||||
|   color: #666; | ||||
|   border-top: 1px solid #eee; | ||||
| } | ||||
|  | ||||
| .footer-legend { | ||||
|   display: flex; | ||||
|   gap: 1.25rem; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .footer-legend-item { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.6rem; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| /* Creamos una clase base para ambos iconos para compartir tamaño */ | ||||
| .legend-icon { | ||||
|   display: inline-block; | ||||
|   width: 14px;   /* Tamaño base para ambos iconos */ | ||||
|   height: 14px; | ||||
|   border-radius: 50%; | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| /* Estilo para el icono de "Bancas en juego" (círculo sólido) */ | ||||
| .legend-icon--solid { | ||||
|   background-color: #888; | ||||
|   border: 1px solid #777; | ||||
| } | ||||
|  | ||||
| /* Estilo para el icono de "Bancas previas" (anillo translúcido) */ | ||||
| .legend-icon--ring { | ||||
|   background-color: rgba(136, 136, 136, 0.3); /* #888 con opacidad */ | ||||
|   border: 1px solid #888; /* Borde sólido del mismo color */ | ||||
| } | ||||
|  | ||||
| .footer-timestamp { | ||||
|   font-weight: 500; | ||||
|   font-size: 0.75em; | ||||
| } | ||||
|  | ||||
| .congreso-summary { | ||||
|   /* --- CAMBIO PRINCIPAL: Se reduce la proporción del panel de datos --- */ | ||||
|   flex: 1 1 35%; | ||||
|   flex: 1;  | ||||
|   border-left: 1px solid #e0e0e0; | ||||
|   /* Se reduce el padding para dar aún más espacio al gráfico */ | ||||
|   padding-left: 1rem; | ||||
|   padding-left: 1.25rem; /* Un poco más de padding */ | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   justify-content: flex-start;  | ||||
| } | ||||
|  | ||||
| .congreso-summary h3 { | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 0.75rem; /* Margen inferior reducido */ | ||||
|   font-size: 1.4em; | ||||
|   color: #212529; | ||||
| } | ||||
|  | ||||
| .chamber-tabs { | ||||
|   display: flex; | ||||
|   margin-bottom: 1.5rem; | ||||
|   margin-bottom: 1rem; /* Margen inferior reducido */ | ||||
|   border: 1px solid #dee2e6; | ||||
|   border-radius: 6px; | ||||
|   overflow: hidden; | ||||
| @@ -66,7 +178,7 @@ | ||||
|  | ||||
| .chamber-tabs button { | ||||
|   flex: 1; | ||||
|   padding: 0.75rem 0.5rem; | ||||
|   padding: 0.5rem 0.5rem; | ||||
|   border: none; | ||||
|   background-color: #f8f9fa; | ||||
|   color: #6c757d; | ||||
| @@ -94,7 +206,7 @@ | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: baseline; | ||||
|   margin-bottom: 0.5rem; | ||||
|   margin-bottom: 0.25rem; /* Margen inferior muy reducido */ | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| @@ -107,7 +219,15 @@ | ||||
| .congreso-summary hr { | ||||
|   border: none; | ||||
|   border-top: 1px solid #e0e0e0; | ||||
|   margin: 1.5rem 0; | ||||
|   margin: 1rem 0; /* Margen vertical reducido */ | ||||
| } | ||||
|  | ||||
| /* Contenedor de la lista de partidos para aplicar el scroll */ | ||||
| .partido-lista-container { | ||||
|     flex-grow: 1; /* Ocupa el espacio vertical disponible */ | ||||
|     overflow-y: auto; /* Muestra el scrollbar si es necesario */ | ||||
|     min-height: 0; /* Truco de Flexbox para que el scroll funcione */ | ||||
|     padding-right: 8px; /* Espacio para el scrollbar */ | ||||
| } | ||||
|  | ||||
| .partido-lista { | ||||
| @@ -119,14 +239,14 @@ | ||||
| .partido-lista li { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   margin-bottom: 0.75rem; | ||||
|   margin-bottom: 0.85rem; /* Un poco más de espacio entre items */ | ||||
| } | ||||
|  | ||||
| .partido-color-box { | ||||
|   width: 14px; | ||||
|   height: 14px; | ||||
|   border-radius: 3px; | ||||
|   margin-right: 10px; | ||||
|   width: 16px;  /* Cuadro de color más grande */ | ||||
|   height: 16px; | ||||
|   border-radius: 4px; /* Un poco más cuadrado */ | ||||
|   margin-right: 12px; | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| @@ -139,19 +259,54 @@ | ||||
|   font-size: 1.1em; | ||||
| } | ||||
|  | ||||
| /* --- Media Query para Responsividad Móvil --- */ | ||||
| /* --- Media Query para Responsividad Móvil (HASTA 768px) --- */ | ||||
| @media (max-width: 768px) { | ||||
|   .congreso-container { | ||||
|     flex-direction: column; | ||||
|     padding: 1.5rem; | ||||
|     padding: 0.5rem; | ||||
|     height: auto; | ||||
|     max-height: none; | ||||
|   } | ||||
|  | ||||
|   .congreso-summary { | ||||
|     border-left: none; | ||||
|     padding-left: 0; | ||||
|     margin-top: 2rem; | ||||
|     border-top: 1px solid #e0e0e0; | ||||
|     padding-top: 1.5rem; | ||||
|   } | ||||
|  | ||||
|   .partido-lista-container { | ||||
|     overflow-y: visible; | ||||
|     max-height: none; | ||||
|   } | ||||
|  | ||||
|   .congreso-footer { | ||||
|     flex-direction: column; /* Apila la leyenda y el timestamp verticalmente */ | ||||
|     align-items: flex-start; /* Alinea todo a la izquierda */ | ||||
|     gap: 0.5rem; /* Añade un pequeño espacio entre la leyenda y el timestamp */ | ||||
|     padding: 0.75rem 0rem; /* Ajusta el padding para móvil */ | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .footer-legend { | ||||
|     gap: 0.75rem; /* Reduce el espacio entre los items de la leyenda */ | ||||
|   } | ||||
|  | ||||
|   .footer-legend-item{ | ||||
|     font-size: 1em; | ||||
|   } | ||||
|  | ||||
|   .footer-timestamp { | ||||
|     font-size: 0.75em; /* Reduce el tamaño de la fuente para que quepa mejor */ | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| /* --- Media Query para Escritorio (DESDE 769px en adelante) --- */ | ||||
| @media (min-width: 769px) { | ||||
|   .congreso-container { | ||||
|     flex-direction: row; | ||||
|     align-items: stretch; | ||||
|     height: 500px;  | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user