Feat Widgets Cards y Optimización de Consultas
This commit is contained in:
		| @@ -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> | ||||
|     ); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user