refactor: Migra todos los widgets nacionales a CSS Modules para encapsular estilos

Esta refactorización modifica la forma en que los widgets manejan sus estilos para prevenir conflictos con los CSS de los sitios anfitriones donde se incrustan.

Se ha migrado el sistema de estilos de CSS global a CSS Modules para todos los componentes principales y sus hijos, asegurando que todas las clases sean únicas y estén aisladas.

Cambios principales:
- Se actualizan los componentes .tsx para importar y usar los módulos de estilos (`import styles from ...`).
- Se renombran los archivos `.css` a `.module.css`.
- Se añade una regla en cada módulo para proteger la `font-family` y el `box-sizing` del widget, evitando que sean sobreescritos por estilos externos.
- Se ajustan los selectores para librerías de terceros (react-select, react-simple-maps) usando `:global()` para mantener la compatibilidad.
- Se mueven las variables CSS de `:root` a las clases principales de cada widget para evitar colisiones en el scope global.

Como resultado, los widgets (`HomeCarouselWidget`, `PanelNacionalWidget`, `ResultadosNacionalesCardsWidget`, `CongresoNacionalWidget`) son ahora más robustos, portátiles y visualmente consistentes en cualquier entorno.
This commit is contained in:
2025-10-04 20:41:23 -03:00
parent fa261ba828
commit ce4fc52d4a
21 changed files with 1476 additions and 1479 deletions

View File

@@ -27,7 +27,7 @@ export const DevAppLegislativas = () => {
const sectionStyle = { const sectionStyle = {
border: '2px solid #007bff', border: '2px solid #007bff',
borderRadius: '8px', borderRadius: '8px',
padding: '1rem 2rem', padding: '2px',
marginTop: '3rem', marginTop: '3rem',
marginBottom: '3rem', marginBottom: '3rem',
backgroundColor: '#f8f9fa' backgroundColor: '#f8f9fa'

View File

@@ -0,0 +1,282 @@
/* src/features/legislativas/nacionales/CongresoNacionalWidget.module.css */
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
.congresoContainer,
.congresoContainer * {
font-family: "Public Sans", system-ui, sans-serif !important;
box-sizing: border-box;
}
.congresoContainer {
display: flex;
gap: 1.5rem;
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 1rem;
border-radius: 8px;
max-width: 900px;
margin: 20px auto;
color: #333333;
--primary-accent-color: #007bff; /* Movida aquí desde :root */
}
.congresoGrafico {
flex: 2;
min-width: 300px;
display: flex;
flex-direction: column;
}
.congresoHemicicloWrapper {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.congresoHemicicloWrapper.isHovering :global(.party-block:not(:hover)) {
opacity: 0.3;
}
.congresoGrafico svg {
width: 100%;
height: auto;
animation: fadeIn 0.8s ease-in-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
.congresoFooter {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.5rem 0 0.5rem;
margin-top: auto;
font-size: 0.8em;
color: #666;
border-top: 1px solid #eee;
}
.footerLegend {
display: flex;
gap: 1.25rem;
align-items: center;
}
.footerLegendItem {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1.1em;
}
.legendIcon {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
box-sizing: border-box;
}
.legendIconSolid {
background-color: #888;
border: 1px solid #777;
}
.legendIconRing {
background-color: rgba(136, 136, 136, 0.3);
border: 1px solid #888;
}
.footerTimestamp {
font-weight: 500;
font-size: 0.75em;
}
.congresoSummary {
flex: 1;
border-left: 1px solid #e0e0e0;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.congresoSummary h3 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.4em;
color: #212529;
}
.chamberTabs {
display: flex;
margin-bottom: 1rem;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
}
.chamberTabs button {
flex: 1;
padding: 0.5rem 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;
}
.chamberTabs button:first-child {
border-right: 1px solid #dee2e6;
}
.chamberTabs button:hover {
background-color: #e9ecef;
}
.chamberTabs button.active {
background-color: var(--primary-accent-color);
color: #ffffff;
}
.summaryMetric {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.25rem;
font-size: 1.1em;
}
.summaryMetric strong {
font-size: 1.5em;
font-weight: 700;
color: var(--primary-accent-color);
}
.congresoSummary hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1rem 0;
}
.partidoListaContainer {
flex-grow: 1;
overflow-y: auto;
min-height: 0;
padding-right: 8px;
}
.partidoLista {
list-style: none;
padding: 0;
margin: 0;
}
.partidoLista li {
display: flex;
align-items: center;
margin-bottom: 0.85rem;
}
.partidoColorBox {
width: 16px;
height: 16px;
border-radius: 4px;
margin-right: 12px;
flex-shrink: 0;
}
.partidoNombre {
flex-grow: 1;
}
.partidoBancas {
font-weight: 700;
font-size: 1.1em;
}
@media (max-width: 768px) {
.congresoContainer {
flex-direction: column;
padding: 0.5rem;
height: auto;
max-height: none;
}
.congresoSummary {
border-left: none;
padding-left: 0;
border-top: 1px solid #e0e0e0;
}
.partidoListaContainer {
overflow-y: visible;
max-height: none;
}
.congresoFooter {
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0rem;
}
.footerLegend {
gap: 0.75rem;
}
.footerLegendItem{
font-size: 1em;
}
.footerTimestamp {
font-size: 0.75em;
}
}
@media (min-width: 769px) {
.congresoContainer {
flex-direction: row;
align-items: stretch;
height: 500px;
}
}
/* --- ESTILOS PARA TOOLTIP --- */
/* Usamos :global() para apuntar a clases e IDs generados por la librería react-tooltip */
:global(.seat-tooltip) {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 8px;
background-color: white;
}
:global(.seat-tooltip img) {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ccc;
}
:global(.seat-tooltip p) {
margin: 0;
font-size: 12px;
font-weight: bold;
color: #333;
}
:global(#seat-tooltip.react-tooltip) {
opacity: 1 !important;
background-color: white;
}

View File

@@ -5,7 +5,8 @@ import { Tooltip } from 'react-tooltip';
import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout'; import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout';
import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout'; import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout';
import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService'; import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService';
import '../provinciales/CongresoWidget.css'; // 1. La importación de CSS ahora se hace como un módulo
import styles from './CongresoNacionalWidget.module.css';
interface CongresoNacionalWidgetProps { interface CongresoNacionalWidgetProps {
eleccionId: number; eleccionId: number;
@@ -71,11 +72,12 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
return adjustedPartyData; return adjustedPartyData;
}, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]); }, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]);
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return ( return (
<div className="congreso-container"> <div className={styles.congresoContainer}>
<div className="congreso-grafico"> <div className={styles.congresoGrafico}>
<div <div
className={`congreso-hemiciclo-wrapper ${isHovering ? 'is-hovering' : ''}`} className={`${styles.congresoHemicicloWrapper} ${isHovering ? styles.isHovering : ''}`}
onMouseEnter={() => setIsHovering(true)} onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} onMouseLeave={() => setIsHovering(false)}
> >
@@ -92,53 +94,51 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
/> />
} }
</div> </div>
<div className="congreso-footer"> <div className={styles.congresoFooter}>
<div className="footer-legend"> <div className={styles.footerLegend}>
<div className="footer-legend-item"> <div className={styles.footerLegendItem}>
{/* Usamos la nueva clase CSS para el círculo sólido */} <span className={`${styles.legendIcon} ${styles.legendIconSolid}`}></span>
<span className="legend-icon legend-icon--solid"></span>
<span>Bancas en juego</span> <span>Bancas en juego</span>
</div> </div>
<div className="footer-legend-item"> <div className={styles.footerLegendItem}>
{/* Reemplazamos el SVG por un span con la nueva clase para el anillo */} <span className={`${styles.legendIcon} ${styles.legendIconRing}`}></span>
<span className="legend-icon legend-icon--ring"></span>
<span>Bancas previas</span> <span>Bancas previas</span>
</div> </div>
</div> </div>
<div className="footer-timestamp"> <div className={styles.footerTimestamp}>
Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)} Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)}
</div> </div>
</div> </div>
</div> </div>
<div className="congreso-summary"> <div className={styles.congresoSummary}>
<div className="chamber-tabs"> <div className={styles.chamberTabs}>
<button className={camaraActiva === 'diputados' ? 'active' : ''} onClick={() => setCamaraActiva('diputados')}> <button className={camaraActiva === 'diputados' ? styles.active : ''} onClick={() => setCamaraActiva('diputados')}>
Diputados Diputados
</button> </button>
<button className={camaraActiva === 'senadores' ? 'active' : ''} onClick={() => setCamaraActiva('senadores')}> <button className={camaraActiva === 'senadores' ? styles.active : ''} onClick={() => setCamaraActiva('senadores')}>
Senadores Senadores
</button> </button>
</div> </div>
<h3>{datosCamaraActual.camaraNombre}</h3> <h3>{datosCamaraActual.camaraNombre}</h3>
<div className="summary-metric"> <div className={styles.summaryMetric}>
<span>Total de Bancas</span> <span>Total de Bancas</span>
<strong>{datosCamaraActual.totalBancas}</strong> <strong>{datosCamaraActual.totalBancas}</strong>
</div> </div>
<div className="summary-metric"> <div className={styles.summaryMetric}>
<span>Bancas en Juego</span> <span>Bancas en Juego</span>
<strong>{datosCamaraActual.bancasEnJuego}</strong> <strong>{datosCamaraActual.bancasEnJuego}</strong>
</div> </div>
<hr /> <hr />
<div className="partido-lista-container"> <div className={styles.partidoListaContainer}>
<ul className="partido-lista"> <ul className={styles.partidoLista}>
{partidosOrdenados {partidosOrdenados
.filter(p => p.bancasTotales > 0) .filter(p => p.bancasTotales > 0)
.map((partido: PartidoComposicionNacional) => ( .map((partido: PartidoComposicionNacional) => (
<li key={partido.id}> <li key={partido.id}>
<span className="partido-color-box" style={{ backgroundColor: partido.color || '#808080' }}></span> <span className={styles.partidoColorBox} style={{ backgroundColor: partido.color || '#808080' }}></span>
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span> <span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
<strong <strong
className="partido-bancas" className={styles.partidoBancas}
title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`} title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`}
> >
{partido.bancasTotales} {partido.bancasTotales}
@@ -155,7 +155,7 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => { export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => {
return ( return (
<Suspense fallback={<div className="congreso-container loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}> <Suspense fallback={<div className={`${styles.congresoContainer} ${styles.loading}`} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}>
<WidgetContent eleccionId={eleccionId} /> <WidgetContent eleccionId={eleccionId} />
</Suspense> </Suspense>
); );

View File

@@ -1,240 +0,0 @@
/* src/features/legislativas/nacionales/HomeCarouselWidget.css */
.home-carousel-widget {
--primary-text: #212529;
--secondary-text: #6c757d;
--border-color: #dee2e6;
--background-light: #f8f9fa;
--background-white: #ffffff;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
--font-family-sans: "Roboto", system-ui, sans-serif;
}
.home-carousel-widget {
font-family: var(--font-family-sans);
background-color: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
max-width: 1200px;
margin: 2rem auto;
}
.widget-title {
font-size: 1.2rem;
font-weight: 900;
color: var(--primary-text);
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.top-stats-bar {
display: flex;
justify-content: space-around;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.3rem 0.5rem;
margin-bottom: 0.5rem;
}
.top-stats-bar > div {
display: flex;
align-items: baseline;
gap: 0.5rem;
border-right: 1px solid var(--border-color);
padding: 0 0.5rem;
flex-grow: 1;
justify-content: center;
}
.top-stats-bar > div:last-child { border-right: none; }
.top-stats-bar span { font-size: 0.9rem; color: var(--secondary-text); }
.top-stats-bar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); }
.candidate-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem;
box-shadow: var(--shadow);
height: 100%;
border-left: 5px solid;
border-left-color: var(--candidate-color, #ccc);
position: relative;
}
.candidate-photo-wrapper {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background-color: var(--candidate-color, #e9ecef);
}
.candidate-photo {
width: 100%;
height: 100%;
object-fit: cover;
box-sizing: border-box;
}
.candidate-details {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
}
.candidate-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items:flex-start;
gap: 0.1rem;
min-width: 0;
margin-right: 0.75rem;
}
.candidate-name, .party-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
text-transform: uppercase;
}
.candidate-name {
font-size: 0.95rem;
text-align: left;
font-weight: 700;
color: var(--primary-text);
}
.party-name {
font-size: 0.8rem;
text-align: left;
text-transform: uppercase;
color: var(--secondary-text);
text-transform: uppercase;
}
.candidate-results { text-align: right; flex-shrink: 0; }
.percentage {
display: block;
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-text);
line-height: 1.1;
}
.votes {
font-size: 0.75rem;
color: var(--secondary-text);
white-space: nowrap;
}
.swiper-slide:not(:last-child) .candidate-card::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}
.swiper-button-prev, .swiper-button-next {
width: 30px; height: 30px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
color: var(--secondary-text);
}
.swiper-button-prev:after, .swiper-button-next:after {
font-size: 18px;
font-weight: bold;
}
.swiper-button-prev { left: -10px; }
.swiper-button-next { right: -10px; }
.swiper-button-disabled { opacity: 0; pointer-events: none; }
.widget-footer {
text-align: right;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.5rem;
}
.short-text {
display: none; /* Oculto por defecto en la vista de escritorio */
}
/* --- INICIO DE LA SECCIÓN DE ESTILOS PARA MÓVIL --- */
@media (max-width: 768px) {
.home-carousel-widget {
padding: 0.75rem;
}
/* 1. Centrar el título en móvil */
.widget-title {
text-align: center;
font-size: 1.1rem;
}
/* 2. Reestructurar la barra de estadísticas a 2x2 y usar textos cortos */
.top-stats-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.2rem;
padding: 0.3rem;
}
.top-stats-bar > div {
padding: 0.25rem 0.5rem;
border-right: none; /* Quitar todos los bordes derechos */
}
.top-stats-bar > div:nth-child(odd) {
border-right: 1px solid var(--border-color); /* Restablecer borde solo para la columna izquierda */
}
/* Lógica de visibilidad de textos */
.long-text {
display: none; /* Ocultar el texto largo en móvil */
}
.short-text {
display:inline; /* Mostrar el texto corto en móvil */
}
/* Reducir fuentes para que quepan mejor */
.top-stats-bar span { font-size: 0.8rem; text-align: left; }
.top-stats-bar strong { font-size: 0.85rem; text-align: right;}
/* --- Botones del Carrusel (sin cambios) --- */
.swiper-button-prev, .swiper-button-next {
width: 32px;
height: 32px;
top: 45%;
}
.swiper-button-prev { left: 2px; }
.swiper-button-next { right: 2px; }
/* --- Ajustes en la tarjeta (sin cambios) --- */
.candidate-card { gap: 0.5rem; padding: 0.5rem; }
.candidate-photo-wrapper { width: 50px; height: 50px; }
.candidate-name { font-size: 0.9rem; }
.percentage { font-size: 1.1rem; }
.votes { font-size: 0.7rem; }
/* 3. Centrar el footer en móvil */
.widget-footer {
text-align: center;
}
}

View File

@@ -0,0 +1,309 @@
/* src/features/legislativas/nacionales/HomeCarouselWidget.module.css */
.homeCarouselWidget {
--primary-text: #212529;
--secondary-text: #6c757d;
--border-color: #dee2e6;
--background-light: #f8f9fa;
--background-white: #ffffff;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
--font-family-sans: "Roboto", system-ui, sans-serif;
}
.homeCarouselWidget,
.homeCarouselWidget * {
font-family: var(--font-family-sans) !important;
box-sizing: border-box;
}
.homeCarouselWidget {
background-color: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
max-width: 1200px;
margin: 2rem auto;
position: relative;
}
.carouselContainer {
position: relative;
}
.widgetTitle {
font-size: 1.2rem;
font-weight: 900;
color: var(--primary-text);
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.topStatsBar {
display: flex;
justify-content: space-around;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.3rem 0.5rem;
margin-bottom: 0.5rem;
}
.topStatsBar > div {
display: flex;
align-items: baseline;
gap: 0.5rem;
border-right: 1px solid var(--border-color);
padding: 0 0.5rem;
flex-grow: 1;
justify-content: center;
}
.topStatsBar > div:last-child { border-right: none; }
.topStatsBar span { font-size: 0.9rem; color: var(--secondary-text); }
.topStatsBar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); }
.candidateCard {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem;
box-shadow: var(--shadow);
border-left: 5px solid;
border-left-color: var(--candidate-color, #ccc);
position: relative;
}
.candidatePhotoWrapper {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background-color: var(--candidate-color, #e9ecef);
}
.candidatePhoto {
width: 100% !important;
height: 100% !important;
object-fit: cover;
box-sizing: border-box;
}
.candidateDetails {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
}
.candidateInfo {
display: flex;
flex-direction: column;
justify-content: center;
align-items:flex-start;
gap: 0.1rem;
min-width: 0;
margin-right: 0.75rem;
}
.candidateName, .partyName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
margin: 0;
color: var(--primary-text);
text-align: left;
}
.candidateName {
font-size: 0.95rem;
font-weight: 700;
}
.partyName {
font-size: 0.8rem;
text-transform: uppercase;
color: var(--secondary-text);
font-weight: 400;
}
.candidateResults { text-align: right; flex-shrink: 0; }
.percentage {
display: block;
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-text);
line-height: 1.1;
}
.votes {
font-size: 0.75rem;
color: var(--secondary-text);
white-space: nowrap;
}
/* Estilo base para ambos botones */
.navButton {
width: 30px;
height: 30px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-top: 0;
z-index: 10;
cursor: pointer;
}
/* Usamos el pseudo-elemento ::after para mostrar el icono SVG como fondo */
.navButton::after {
content: ''; /* Es necesario para que el pseudo-elemento se muestre */
display: block;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: center;
/* Ajustamos el tamaño del icono dentro del botón */
background-size: 75%;
}
/* Posición y contenido específico para cada botón */
.navButtonPrev {
left: 10px;
}
.navButtonPrev::after {
/* SVG de flecha izquierda (chevron) codificado en Base64 */
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSIxNSA2IDkgMTIgMTUgMTgiPjwvcG9seWxpbmU+PC9zdmc+");
}
.navButtonNext {
right: 10px;
}
.navButtonNext::after {
/* SVG de flecha derecha (chevron) codificado en Base64 */
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSI5IDYgMTUgMTIgOSAxOCI+PC9wb2x5bGluZT48L3N2Zz4=");
}
/* Swiper añade esta clase al botón cuando está deshabilitado */
.navButton.swiper-button-disabled {
opacity: 0;
pointer-events: none;
}
.homeCarouselWidget :global(.swiper-slide) {
background: transparent !important;
color: initial !important;
text-align: left !important;
height: auto !important;
}
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}
/* --- INICIO DE LA MODIFICACIÓN DE FLECHAS --- */
.homeCarouselWidget :global(.swiper-button-prev),
.homeCarouselWidget :global(.swiper-button-next) {
width: 30px !important;
height: 30px !important;
background-color: rgba(255, 255, 255, 0.9) !important;
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
position: absolute !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
z-index: 10;
}
.homeCarouselWidget :global(.swiper-button-prev:after),
.homeCarouselWidget :global(.swiper-button-next:after) {
display: block !important;
font-family: 'swiper-icons';
font-size: 14px !important;
font-weight: bold !important;
color: var(--primary-text) !important;
}
.homeCarouselWidget :global(.swiper-button-prev) { left: 10px !important; }
.homeCarouselWidget :global(.swiper-button-next) { right: 10px !important; }
.homeCarouselWidget :global(.swiper-button-disabled) {
opacity: 0 !important;
pointer-events: none !important;
}
.widgetFooter {
text-align: right;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.5rem;
}
.shortText {
display: none;
}
@media (max-width: 768px) {
.homeCarouselWidget .widgetTitle { text-align: center; font-size: 1.1rem; }
.homeCarouselWidget .topStatsBar { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.2rem; padding: 0.3rem; }
.homeCarouselWidget .topStatsBar > div { padding: 0.25rem 0.5rem; border-right: none; }
.homeCarouselWidget .topStatsBar > div:nth-child(odd) { border-right: 1px solid var(--border-color); }
.homeCarouselWidget .longText { display: none; }
.homeCarouselWidget .shortText { display:inline; }
.homeCarouselWidget .topStatsBar span { font-size: 0.8rem; text-align: left; }
.homeCarouselWidget .topStatsBar strong { font-size: 0.85rem; text-align: right; }
/* Ajustamos los botones custom en mobile */
.navButton {
width: 32px;
height: 32px;
}
.navButton::after {
line-height: 32px;
}
.navButtonPrev { left: 5px; }
.navButtonNext { right: 5px; }
.homeCarouselWidget .candidateCard { gap: 0.5rem; padding: 0.5rem; }
.homeCarouselWidget .candidatePhotoWrapper { width: 50px; height: 50px; }
.homeCarouselWidget .candidateName { font-size: 0.9rem; }
.homeCarouselWidget .percentage { font-size: 1.1rem; }
.homeCarouselWidget .votes { font-size: 0.7rem; }
.homeCarouselWidget .widgetFooter { text-align: center; }
}
/* Mantenemos estos estilos globales por si acaso */
.homeCarouselWidget :global(.swiper-slide) {
background: transparent !important;
color: initial !important;
text-align: left !important;
height: auto !important;
}
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}

View File

@@ -10,7 +10,7 @@ import { Navigation, A11y } from 'swiper/modules';
import 'swiper/css'; import 'swiper/css';
// @ts-ignore // @ts-ignore
import 'swiper/css/navigation'; import 'swiper/css/navigation';
import './HomeCarouselWidget.css'; import styles from './HomeCarouselWidget.module.css';
interface Props { interface Props {
eleccionId: number; eleccionId: number;
@@ -22,14 +22,12 @@ interface Props {
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`; const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const formatNumber = (num: number) => num.toLocaleString('es-AR'); const formatNumber = (num: number) => num.toLocaleString('es-AR');
// --- Lógica de formateo de fecha ---
const formatDateTime = (dateString: string | undefined | null) => { const formatDateTime = (dateString: string | undefined | null) => {
if (!dateString) return '...'; if (!dateString) return '...';
try { try {
const date = new Date(dateString); const date = new Date(dateString);
// Verificar si la fecha es válida
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return dateString; // Si no se puede parsear, devolver el string original return dateString;
} }
const day = String(date.getDate()).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0'); const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -38,7 +36,7 @@ const formatDateTime = (dateString: string | undefined | null) => {
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`; return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
} catch (e) { } catch (e) {
return dateString; // En caso de cualquier error, devolver el string original return dateString;
} }
}; };
@@ -52,73 +50,81 @@ export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo
if (error || !data) return <div>No se pudieron cargar los datos.</div>; if (error || !data) return <div>No se pudieron cargar los datos.</div>;
return ( return (
<div className="home-carousel-widget"> <div className={styles.homeCarouselWidget}>
<h2 className="widget-title">{titulo}</h2> <h2 className={styles.widgetTitle}>{titulo}</h2>
<div className="top-stats-bar"> <div className={styles.topStatsBar}>
<div> <div>
<span>Participación</span> <span>Participación</span>
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong> <strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
</div> </div>
<div> <div>
<span className="long-text">Mesas escrutadas</span> <span className={styles.longText}>Mesas escrutadas</span>
<span className="short-text">Escrutado</span> <span className={styles.shortText}>Escrutado</span>
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong> <strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
</div> </div>
<div> <div>
<span className="long-text">Votos en blanco</span> <span className={styles.longText}>Votos en blanco</span>
<span className="short-text">En blanco</span> <span className={styles.shortText}>En blanco</span>
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong> <strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
</div> </div>
<div> <div>
<span className="long-text">Votos totales</span> <span className={styles.longText}>Votos totales</span>
<span className="short-text">Votos</span> <span className={styles.shortText}>Votos</span>
<strong>{formatNumber(data.votosTotales)}</strong> <strong>{formatNumber(data.votosTotales)}</strong>
</div> </div>
</div> </div>
<div className={styles.carouselContainer}>
<Swiper <Swiper
modules={[Navigation, A11y]} modules={[Navigation, A11y]}
spaceBetween={16} spaceBetween={16}
slidesPerView={1.15} slidesPerView={1.3}
navigation navigation={{
breakpoints={{ 640: { slidesPerView: 2 }, 1024: { slidesPerView: 3 }, 1200: { slidesPerView: 3.5 } }} // Añadir breakpoint prevEl: `.${styles.navButtonPrev}`,
nextEl: `.${styles.navButtonNext}`,
}}
breakpoints={{
320: { slidesPerView: 1.25, spaceBetween: 10 },
430: { slidesPerView: 1.4, spaceBetween: 12 },
640: { slidesPerView: 2.5 },
1024: { slidesPerView: 3 },
1200: { slidesPerView: 3.5 }
}}
> >
{data.resultados.map(candidato => ( {data.resultados.map(candidato => (
<SwiperSlide key={candidato.agrupacionId}> <SwiperSlide key={candidato.agrupacionId}>
<div className="candidate-card" style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}> <div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
<div className="candidate-photo-wrapper"> <div className={styles.candidatePhotoWrapper}>
<ImageWithFallback <ImageWithFallback
src={candidato.fotoUrl ?? undefined} src={candidato.fotoUrl ?? undefined}
fallbackSrc={`${assetBaseUrl}/default-avatar.png`} fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
alt={candidato.nombreCandidato ?? ''} alt={candidato.nombreCandidato ?? ''}
className="candidate-photo" className={styles.candidatePhoto}
/> />
</div> </div>
<div className="candidate-details"> <div className={styles.candidateDetails}>
<div className="candidate-info"> <div className={styles.candidateInfo}>
{candidato.nombreCandidato ? ( {candidato.nombreCandidato ? (
// CASO 1: Hay un candidato (se muestran dos líneas)
<> <>
<span className="candidate-name"> <span className={styles.candidateName}>
{candidato.nombreCandidato} {candidato.nombreCandidato}
</span> </span>
<span className="party-name"> <span className={styles.partyName}>
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion} {candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
</span> </span>
</> </>
) : ( ) : (
// CASO 2: No hay candidato (se muestra solo una línea) <span className={styles.candidateName}>
<span className="candidate-name">
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion} {candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
</span> </span>
)} )}
</div> </div>
<div className="candidate-results"> <div className={styles.candidateResults}>
<span className="percentage">{formatPercent(candidato.porcentaje)}</span> <span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span>
<span className="votes">{formatNumber(candidato.votos)} votos</span> <span className={styles.votes}>{formatNumber(candidato.votos)} votos</span>
</div> </div>
</div> </div>
@@ -127,7 +133,11 @@ export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo
))} ))}
</Swiper> </Swiper>
<div className="widget-footer"> <div className={`${styles.navButton} ${styles.navButtonPrev}`}></div>
<div className={`${styles.navButton} ${styles.navButtonNext}`}></div>
</div>
<div className={styles.widgetFooter}>
Última actualización: {formatDateTime(data.ultimaActualizacion)} Última actualización: {formatDateTime(data.ultimaActualizacion)}
</div> </div>
</div> </div>

View File

@@ -1,920 +0,0 @@
/* 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;
padding: 10px;
}
.panel-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0;
position: relative;
z-index: 20;
background-color: white;
}
/* Contenedor para alinear título y selector */
.header-top-row {
display: flex;
justify-content: flex-start;
/* Alinea los items al inicio */
align-items: center;
gap: 2rem;
/* Añade un espacio de separación de 2rem entre el selector y el breadcrumb */
}
.categoria-selector {
min-width: 220px;
}
/* El contenedor principal del selector (la parte visible antes de hacer clic) */
.categoria-selector__control {
border-radius: 8px !important;
border: 1px solid #e0e0e0 !important;
box-shadow: none !important;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
/* Estilo cuando el selector está enfocado (seleccionado) */
.categoria-selector__control--is-focused {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
/* El texto del valor seleccionado */
.categoria-selector__single-value {
font-weight: 500;
color: #333;
}
/* El menú desplegable que contiene las opciones */
.categoria-selector__menu {
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border: 1px solid #e0e0e0 !important;
margin-top: 4px !important;
/* Pequeño espacio entre el control y el menú */
}
/* Cada una de las opciones en la lista */
.categoria-selector__option {
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
/* Estilo de una opción cuando pasas el mouse por encima (estado 'focused') */
.categoria-selector__option--is-focused {
background-color: #f0f8ff;
/* Un azul muy claro */
color: #333;
}
/* Estilo de la opción que está actualmente seleccionada */
.categoria-selector__option--is-selected {
background-color: #007bff;
color: white;
}
/* La pequeña línea vertical que separa el contenido del indicador (la flecha) */
.categoria-selector__indicator-separator {
display: none;
/* La ocultamos para un look más limpio */
}
/* El indicador (la flecha hacia abajo) */
.categoria-selector__indicator {
color: #a0a0a0;
transition: color 0.2s;
}
.categoria-selector__indicator:hover {
color: #333;
}
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
.breadcrumbs-container {
display: flex;
align-items: center;
gap: 0.5rem;
/* Espacio entre elementos */
font-size: 1rem;
}
.breadcrumb-item,
.breadcrumb-item-actual {
display: flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 8px;
/* Bordes redondeados para efecto píldora */
transition: background-color 0.2s ease-in-out;
}
.breadcrumb-item {
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
color: #333;
cursor: pointer;
font-weight: 500;
}
.breadcrumb-item:hover {
background-color: #e0e0e0;
border-color: #d1d1d1;
}
.breadcrumb-item-actual {
background-color: transparent;
color: #000;
font-weight: 700;
/* Más peso para el nivel actual */
}
.breadcrumb-icon {
margin-right: 0.4rem;
font-size: 1rem;
}
.breadcrumb-separator {
color: #a0a0a0;
/* Color sutil para el separador */
font-size: 1.2rem;
}
.panel-main-content {
display: flex;
height: 75vh;
min-height: 500px;
transition: all 0.5s ease-in-out;
}
.mapa-column {
flex: 2;
position: relative;
transition: flex 0.5s ease-in-out;
}
.resultados-column {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
transition: all 0.5s ease-in-out;
min-width: 320px;
}
/* --- AJUSTES EN FILAS DE PARTIDOS --- */
.partido-fila {
display: flex;
align-items: center;
gap: 0.75rem;
/* ANTES: 1rem */
padding: 0.75rem 0;
/* ANTES: 1rem 0 */
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 1rem;
}
.partido-logo {
flex-shrink: 0;
width: 65px;
height: 65px;
border-radius: 12px;
box-sizing: border-box;
}
.partido-logo img {
width: 100%;
height: 100%;
border-radius: 12px;
}
.partido-main-content {
flex-grow: 1;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 0.25rem 0.75rem;
/* ANTES: gap: 0.25rem 1rem */
}
.partido-top-row {
display: contents;
}
.partido-info-wrapper {
min-width: 0;
text-align: left;
}
.partido-nombre {
font-weight: 700;
font-size: 1rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
text-transform: uppercase;
}
.partido-nombre-normal {
font-size: 1rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
text-transform: uppercase;
}
.candidato-nombre {
font-size: 0.75rem;
/* ANTES: 0.8rem */
color: #6c757d;
text-transform: uppercase;
font-weight: 500;
line-height: 1.1;
}
.partido-stats {
flex-shrink: 0;
text-align: right;
padding-left: 1rem;
}
.partido-porcentaje {
font-size: 1.35rem;
/* ANTES: 1.5rem */
font-weight: 700;
display: block;
}
.partido-votos {
font-size: 0.9rem;
/* ANTES: 1rem */
color: #666;
display: block;
}
.partido-barra-background {
height: 12px;
/* ANTES: 20px */
background-color: #f0f0f0;
border-radius: 4px;
grid-column: 1 / 3;
}
.partido-barra-foreground {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
}
/* --- AJUSTES EN CÍRCULOS DE ESTADÍSTICAS --- */
.panel-estado-recuento {
display: flex;
justify-content: space-around;
padding-bottom: 1rem;
/* ANTES: 1.5rem */
margin-bottom: 1rem;
/* ANTES: 1.5rem */
border-bottom: 1px solid #e0e0e0;
}
.estado-item {
width: 95px;
/* ANTES: 100px */
text-align: center;
}
.estado-item span {
margin-top: 0.5rem;
font-size: 0.85rem;
/* ANTES: 0.9rem */
color: #666;
display: block;
}
/* --- MAPA Y ELEMENTOS ASOCIADOS --- */
.mapa-componente-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.mapa-render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.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);
}
.rsm-zoomable-group {
transition: transform 0.75s ease-in-out;
}
/* Desactivar la transición durante el arrastre */
.rsm-zoomable-group.panning {
transition: none;
}
.panel-main-content.panel-collapsed .mapa-column {
flex: 1 1 100%;
}
.panel-main-content.panel-collapsed .resultados-column {
flex-basis: 0;
min-width: 0;
max-width: 0;
padding: 0;
overflow: hidden;
}
.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.3rem;
font-weight: bold;
color: #555;
display: flex;
align-items: center;
justify-content: center;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.panel-toggle-btn:hover {
background-color: #f0f0f0;
}
.rsm-geography {
stroke: #000000;
stroke-width: 0.25px;
outline: none;
transition: filter 0.2s ease-in-out;
}
.rsm-geography:not(.selected):hover {
filter: brightness(1.25);
/* Mantenemos el brillo */
stroke: #ffffff;
/* Color del borde a blanco */
stroke-width: 0.25px;
paint-order: stroke;
/* Asegura que el borde se dibuje encima del relleno */
}
.rsm-geography.selected {
stroke: #000000;
stroke-width: 0.25px;
filter: none;
pointer-events: none;
}
.rsm-geography-faded,
.rsm-geography-faded-municipality {
opacity: 0.5;
pointer-events: none;
}
.caba-comuna-geography {
stroke: #000000;
stroke-width: 0.05px;
}
.caba-comuna-geography:not(.selected):hover {
stroke: #000000;
stroke-width: 0.055px;
filter: brightness(1.25);
}
.transition-spinner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.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);
}
}
.caba-magnifier-container {
position: absolute;
height: auto;
transform: translate(-50%, -50%);
pointer-events: none;
}
.caba-lupa-svg {
width: 100%;
height: auto;
pointer-events: none;
}
.caba-lupa-interactive-area {
pointer-events: all;
cursor: pointer;
filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25));
transition: transform 0.2s ease-in-out;
}
.caba-lupa-interactive-area:hover {
filter: brightness(1.15);
stroke: #ffffff;
stroke-width: 0.25px;
}
.skeleton-fila div {
background: #f6f7f8;
background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
background-repeat: no-repeat;
background-size: 800px 104px;
animation: shimmer 1s linear infinite;
border-radius: 4px;
}
.skeleton-logo {
width: 65px;
height: 65px;
}
.skeleton-text {
height: 1em;
}
.skeleton-bar {
height: 20px;
margin-top: 4px;
}
/* --- ESTILOS PARA LOS BOTONES DE ZOOM DEL MAPA --- */
.zoom-controls-container {
position: absolute;
top: 5px;
right: 10px;
z-index: 30;
display: flex;
flex-direction: column;
gap: 5px;
}
.zoom-btn {
width: 40px;
height: 40px;
background-color: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: background-color 0.2s;
}
.zoom-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.zoom-icon-wrapper svg {
width: 20px;
height: 20px;
color: #333;
}
.zoom-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.zoom-btn:hover {
background-color: #f0f0f0;
}
/* --- ESTILOS DE CURSOR PARA EL ARRASTRE DEL MAPA --- */
.map-locked .rsm-geography {
cursor: pointer;
}
.map-pannable .rsm-geography {
cursor: grab;
}
.header-bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
gap: 1rem;
}
.municipio-search-container {
min-width: 280px;
/* Ancho mínimo para el buscador en desktop */
}
/* --- MEDIA QUERY PARA RESPONSIVE (REFACTORIZADA) --- */
@media (max-width: 800px) {
.panel-nacional-container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 0;
border: none;
border-radius: 0;
}
.panel-header {
flex-shrink: 0;
padding: 1rem;
border-radius: 0;
}
.panel-main-content {
flex-grow: 1;
position: relative;
height: auto;
min-height: 0;
}
.panel-toggle-btn {
display: none;
}
.header-top-row {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.categoria-selector {
width: 100%;
}
.mapa-column,
.resultados-column {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
}
.mapa-column {
z-index: 10;
}
.resultados-column {
padding: 1rem;
overflow-y: auto;
z-index: 15;
}
.panel-main-content.mobile-view-mapa .resultados-column {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.panel-main-content.mobile-view-resultados .mapa-column {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.resultados-column {
padding: 0.5rem;
overflow-y: auto;
z-index: 15;
padding-bottom: 50px;
}
.mapa-column .mapa-componente-container,
.mapa-column .mapa-render-area {
height: 100%;
}
.panel-partidos-container {
padding-bottom: 0;
}
.zoom-controls-container, .mapa-volver-btn {
top: 15px;
}
.header-bottom-row {
flex-direction: column;
align-items: stretch;
gap: 1rem;
}
.municipio-search-container {
min-width: 100%;
/* El buscador ocupa todo el ancho en móvil */
}
@media (max-width: 900px) and (orientation: landscape) {
.panel-main-content {
display: flex;
flex-direction: row;
position: static;
height: 85vh;
min-height: 400px;
}
.mapa-column,
.resultados-column {
position: static;
height: auto;
width: auto;
opacity: 1;
visibility: visible;
pointer-events: auto;
flex: 3;
overflow-y: auto;
}
.resultados-column {
flex: 2;
min-width: 300px;
}
.mobile-results-card-container {
display: none;
}
.panel-toggle-btn {
display: flex;
}
}
}
/* --- ESTILOS PARA LA TARJETA DE RESULTADOS EN MÓVIL (ACTUALIZADOS) --- */
.mobile-results-card-container {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
z-index: 40;
width: 95%;
max-width: 450px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease-in-out;
display: flex;
flex-direction: column;
}
.mobile-results-card-container.view-resultados .collapsible-section {
display: none;
}
.mobile-results-card-container.view-resultados .mobile-card-view-toggle {
border-top: none;
}
.collapsible-section {
display: flex;
flex-direction: column;
}
.mobile-results-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 18px;
cursor: pointer;
}
.mobile-results-header .header-info {
display: flex;
align-items: baseline;
gap: 12px;
}
.mobile-results-header .header-info h4 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
}
/* SELECTOR ESPECÍFICO PARA EL TEXTO DE ACCIÓN */
.mobile-results-header .header-info .header-action-text {
font-size: 0.8rem;
color: #6c757d;
font-weight: 500;
text-transform: uppercase;
}
.mobile-results-header .header-toggle-icon {
font-size: 1.5rem;
color: #007bff;
transition: transform 0.3s;
}
.mobile-results-content {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out;
padding: 0 15px;
border-top: 1px solid transparent;
}
.mobile-results-card-container.expanded .mobile-results-content {
max-height: 500px;
opacity: 1;
padding: 5px 15px 15px 15px;
border-top-color: #e0e0e0;
}
.mobile-result-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
border-left: 4px solid;
padding-left: 8px;
}
.mobile-result-row:last-child {
border-bottom: none;
}
.mobile-result-logo {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
box-sizing: border-box;
}
.mobile-result-logo img {
width: 100%;
height: 100%;
border-radius: 8px;
}
.mobile-result-info {
flex-grow: 1;
min-width: 0;
}
.mobile-result-party-name {
display: block;
font-weight: 600;
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-result-candidate-name {
display: block;
font-size: 0.75rem;
color: #6c757d;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mobile-result-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.mobile-result-stats strong {
font-size: 0.95rem;
font-weight: 700;
}
.mobile-result-stats span {
font-size: 0.7rem;
color: #6c757d;
}
.no-results-text {
padding: 1rem;
text-align: center;
color: #6c757d;
font-size: 0.9rem;
}
.mobile-card-view-toggle {
display: flex;
padding: 5px;
background-color: rgba(230, 230, 230, 0.6);
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
.mobile-card-view-toggle .toggle-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 15px;
/* Aumentado para pantallas más grandes */
border: none;
background-color: transparent;
border-radius: 25px;
cursor: pointer;
font-size: 1rem;
/* Mantenido para pantallas más grandes */
font-weight: 500;
color: #555;
transition: all 0.2s ease-in-out;
}
.mobile-card-view-toggle .toggle-btn.active {
background-color: #007bff;
color: white;
box-shadow: 0 2px 5px rgba(0, 123, 255, 0.2);
}
/* Ajustes para pantallas pequeñas como el iPhone SE */
@media (max-width: 380px) {
.mobile-results-header {
padding: 4px 10px;
}
.mobile-results-header .header-info h4 {
font-size: 0.75rem;
text-transform: uppercase;
}
.mobile-results-header .header-info .header-action-text {
font-size: 0.7rem;
}
.mobile-card-view-toggle .toggle-btn {
padding: 6px 10px;
font-size: 0.8rem;
}
}

View File

@@ -0,0 +1,539 @@
/* src/features/legislativas/nacionales/PanelNacional.module.css */
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
.panelNacionalContainer,
.panelNacionalContainer * {
font-family: 'Roboto', sans-serif !important;
box-sizing: border-box;
}
.panelNacionalContainer {
max-width: 1200px;
margin: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
position: relative;
padding: 10px;
}
.panelHeader {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0;
position: relative;
z-index: 20;
background-color: white;
}
.headerTopRow {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 2rem;
}
/* --- ESTILOS PARA REACT-SELECT USANDO MÓDULOS --- */
.categoriaSelectorContainer {
min-width: 220px;
}
.categoriaSelectorContainer :global(.categoriaSelector__control) {
border-radius: 8px !important;
border: 1px solid #e0e0e0 !important;
box-shadow: none !important;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.categoriaSelectorContainer :global(.categoriaSelector__control--is-focused) {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
.categoriaSelectorContainer :global(.categoriaSelector__single-value) {
font-weight: 500;
color: #333;
}
.categoriaSelectorContainer :global(.categoriaSelector__menu) {
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border: 1px solid #e0e0e0 !important;
margin-top: 4px !important;
}
.categoriaSelectorContainer :global(.categoriaSelector__option) {
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.categoriaSelectorContainer :global(.categoriaSelector__option--is-focused) {
background-color: #f0f8ff;
color: #333;
}
.categoriaSelectorContainer :global(.categoriaSelector__option--is-selected) {
background-color: #007bff;
color: white;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator-separator) {
display: none;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator) {
color: #a0a0a0;
transition: color 0.2s;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator:hover) {
color: #333;
}
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
.breadcrumbsContainer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
}
.breadcrumbItem,
.breadcrumbItemActual {
display: flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 8px;
transition: background-color 0.2s ease-in-out;
}
.breadcrumbItem {
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
color: #333;
cursor: pointer;
font-weight: 500;
}
.breadcrumbItem:hover {
background-color: #e0e0e0;
border-color: #d1d1d1;
}
.breadcrumbItemActual {
background-color: transparent;
color: #000;
font-weight: 700;
}
.breadcrumbIcon {
margin-right: 0.4rem;
font-size: 1rem;
}
.breadcrumbSeparator {
color: #a0a0a0;
font-size: 1.2rem;
}
.panelMainContent {
display: flex;
height: 75vh;
min-height: 500px;
transition: all 0.5s ease-in-out;
}
.mapaColumn {
flex: 2;
position: relative;
transition: flex 0.5s ease-in-out;
}
.resultadosColumn {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
transition: all 0.5s ease-in-out;
min-width: 320px;
}
.partidoFila {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 1rem;
}
.partidoLogo {
flex-shrink: 0;
width: 65px;
height: 65px;
border-radius: 12px;
}
.partidoLogo img {
width: 100%;
height: 100%;
border-radius: 12px;
}
.partidoMainContent {
flex-grow: 1;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 0.25rem 0.75rem;
}
.partidoTopRow { display: contents; }
.partidoInfoWrapper { min-width: 0; text-align: left; }
.partidoNombre {
font-weight: 700;
font-size: 1rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
text-transform: uppercase;
}
.partidoNombreNormal {
font-size: 1rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
text-transform: uppercase;
}
.candidatoNombre {
font-size: 0.75rem;
color: #6c757d;
text-transform: uppercase;
font-weight: 500;
line-height: 1.1;
}
.partidoStats { flex-shrink: 0; text-align: right; padding-left: 1rem; }
.partidoPorcentaje { font-size: 1.35rem; font-weight: 700; display: block; }
.partidoVotos { font-size: 0.9rem; color: #666; display: block; }
.partidoBarraBackground {
height: 12px;
background-color: #f0f0f0;
border-radius: 4px;
grid-column: 1 / 3;
}
.partidoBarraForeground {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
}
.panelEstadoRecuento {
display: flex;
justify-content: space-around;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e0e0e0;
}
.estadoItem {
width: 95px;
text-align: center;
}
.estadoItem span {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #666;
display: block;
}
/* --- ESTILOS PARA MAPA --- */
/* --- INICIO DE LA CORRECCIÓN --- */
.mapaComponenteContainer {
width: 100%;
height: 100%;
position: relative; /* Esta línea es la que faltaba */
overflow: hidden;
}
/* --- FIN DE LA CORRECCIÓN --- */
.mapaRenderArea { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.mapaVolverBtn,
.zoomBtn {
background-color: #ffffff;
border: 1px solid #e0e0e0; /* Borde más sutil */
border-radius: 8px; /* Bordes más suaves */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); /* Sombra más pronunciada y moderna */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out; /* Transición suave para todos los efectos */
color: #333;
}
.mapaVolverBtn:hover,
.zoomBtn:hover:not(:disabled) {
border-color: #007bff; /* Borde de acento */
color: #007bff; /* Icono/texto de acento */
transform: translateY(-2px); /* Efecto de "levantar" */
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
}
.mapaVolverBtn:active,
.zoomBtn:active:not(:disabled) {
transform: translateY(0px); /* Botón "presionado" */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); /* Sombra interior */
background-color: #f8f9fa;
}
.mapaVolverBtn {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
padding: 8px 12px;
font-weight: 500;
}
:global(.rsm-zoomable-group) { transition: transform 0.75s ease-in-out; }
:global(.rsm-zoomable-group.panning) { transition: none; }
.panelMainContent.panelCollapsed .mapaColumn { flex: 1 1 100%; }
.panelMainContent.panelCollapsed .resultadosColumn {
flex-basis: 0;
min-width: 0;
max-width: 0;
padding: 0;
overflow: hidden;
}
.panelToggleBtn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
z-index: 10;
width: 30px;
height: 50px;
border: 1px solid #ccc;
background-color: white;
border-radius: 4px 0 0 4px;
cursor: pointer;
font-size: 1.3rem;
font-weight: bold;
color: #555;
display: flex;
align-items: center;
justify-content: center;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.panelToggleBtn:hover { background-color: #f0f0f0; }
:global(.rsm-geography) {
stroke: #000000;
stroke-width: 0.25px;
outline: none;
transition: filter 0.2s ease-in-out;
}
:global(.rsm-geography:not(.selected):hover) {
filter: brightness(1.25);
stroke: #ffffff;
stroke-width: 0.25px;
paint-order: stroke;
}
:global(.rsm-geography.selected) {
stroke: #000000;
stroke-width: 0.25px;
filter: none;
pointer-events: none;
}
:global(.rsm-geography-faded), :global(.rsm-geography-faded-municipality) {
opacity: 0.5;
pointer-events: none;
}
:global(.caba-comuna-geography) {
stroke: #000000;
stroke-width: 0.05px;
}
:global(.caba-comuna-geography:not(.selected):hover) {
stroke: #000000;
stroke-width: 0.055px;
filter: brightness(1.25);
}
.transitionSpinner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.transitionSpinner::after {
content: '';
width: 50px;
height: 50px;
border: 5px solid rgba(0, 0, 0, 0.2);
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.cabaMagnifierContainer { position: absolute; height: auto; transform: translate(-50%, -50%); pointer-events: none; }
.cabaLupaSvg { width: 100%; height: auto; pointer-events: none; }
.cabaLupaInteractiveArea { pointer-events: all; cursor: pointer; filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25)); transition: transform 0.2s ease-in-out; }
.cabaLupaInteractiveArea:hover { filter: brightness(1.15); stroke: #ffffff; stroke-width: 0.25px; }
.skeletonFila div {
background: #f6f7f8;
background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
background-repeat: no-repeat;
background-size: 800px 104px;
animation: shimmer 1s linear infinite;
border-radius: 4px;
}
.skeletonLogo { width: 65px; height: 65px; }
.skeletonText { height: 1em; }
.skeletonBar { height: 20px; margin-top: 4px; }
.zoomControlsContainer {
position: absolute;
top: 10px;
right: 10px;
z-index: 30;
display: flex;
flex-direction: column;
gap: 8px; /* Un poco más de espacio */
}
/* Estilos específicos para los botones de zoom */
.zoomBtn {
width: 40px;
height: 40px;
}
.zoomIconWrapper svg {
width: 22px; /* Iconos ligeramente más grandes */
height: 22px;
}
/* Estilo para el botón deshabilitado */
.zoomBtn:disabled,
.zoomBtn.disabled { /* Cubrimos ambos casos */
opacity: 0.6;
cursor: not-allowed;
background-color: #f8f9fa;
}
:global(.map-locked .rsm-geography) { cursor: pointer; }
:global(.map-pannable .rsm-geography) { cursor: grab; }
.headerBottomRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
gap: 1rem;
}
.municipioSearchContainer { min-width: 280px; }
@media (max-width: 800px) {
.panelNacionalContainer { display: flex; flex-direction: column; height: 100vh; padding: 0; border: none; border-radius: 0; }
.panelHeader { flex-shrink: 0; padding: 1rem; border-radius: 0; }
.panelMainContent { flex-grow: 1; position: relative; height: auto; min-height: 0; }
.panelToggleBtn { display: none; }
.headerTopRow { flex-direction: column; align-items: flex-start; gap: 1rem; }
.categoriaSelectorContainer { width: 100%; }
.mapaColumn,
.resultadosColumn { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; }
.mapaColumn { z-index: 10; }
.resultadosColumn { padding: 1rem; overflow-y: auto; z-index: 15; }
.panelMainContent.mobile-view-mapa .resultadosColumn { opacity: 0; visibility: hidden; pointer-events: none; }
.panelMainContent.mobile-view-resultados .mapaColumn { opacity: 0; visibility: hidden; pointer-events: none; }
.resultadosColumn { padding: 0.5rem; padding-bottom: 50px; }
.mapaColumn .mapaComponenteContainer, .mapaColumn .mapaRenderArea { height: 100%; }
.panelPartidosContainer { padding-bottom: 0; }
.zoomControlsContainer, .mapaVolverBtn { top: 15px; }
.headerBottomRow { flex-direction: column; align-items: stretch; gap: 1rem; }
.municipioSearchContainer { min-width: 100%; }
@media (max-width: 900px) and (orientation: landscape) {
.panelMainContent { display: flex; flex-direction: row; position: static; height: 85vh; min-height: 400px; }
.mapaColumn,
.resultadosColumn { position: static; height: auto; width: auto; opacity: 1; visibility: visible; pointer-events: auto; flex: 3; overflow-y: auto; }
.resultadosColumn { flex: 2; min-width: 300px; }
.mobileResultsCardContainer { display: none; }
.panelToggleBtn { display: flex; }
}
}
.mobileResultsCardContainer {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
z-index: 40;
width: 95%;
max-width: 450px;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.1);
transition: all 0.3s ease-in-out;
display: flex;
flex-direction: column;
}
.mobileResultsCardContainer.view-resultados .collapsibleSection { display: none; }
.mobileResultsCardContainer.view-resultados .mobileCardViewToggle { border-top: none; }
.collapsibleSection { display: flex; flex-direction: column; }
.mobileResultsHeader { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; cursor: pointer; }
.mobileResultsHeader .headerInfo { display: flex; align-items: baseline; gap: 12px; }
.mobileResultsHeader .headerInfo h4 { margin: 0; font-size: 1.2rem; font-weight: 700; }
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.8rem; color: #6c757d; font-weight: 500; text-transform: uppercase; }
.mobileResultsHeader .headerToggleIcon { font-size: 1.5rem; color: #007bff; transition: transform 0.3s; }
.mobileResultsContent { max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; padding: 0 15px; border-top: 1px solid transparent; }
.mobileResultsCardContainer.expanded .mobileResultsContent { max-height: 500px; opacity: 1; padding: 5px 15px 15px 15px; border-top-color: #e0e0e0; }
.mobileResultRow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; border-left: 4px solid; padding-left: 8px; }
.mobileResultRow:last-child { border-bottom: none; }
.mobileResultLogo { flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; }
.mobileResultLogo img { width: 100%; height: 100%; border-radius: 8px; }
.mobileResultInfo { flex-grow: 1; min-width: 0; }
.mobileResultPartyName { display: block; font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobileResultCandidateName { display: block; font-size: 0.75rem; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobileResultStats { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; }
.mobileResultStats strong { font-size: 0.95rem; font-weight: 700; }
.mobileResultStats span { font-size: 0.7rem; color: #6c757d; }
.noResultsText { padding: 1rem; text-align: center; color: #6c757d; font-size: 0.9rem; }
.mobileCardViewToggle { display: flex; padding: 5px; background-color: rgba(230, 230, 230, 0.6); border-top: 1px solid rgba(0, 0, 0, 0.08); }
.mobileCardViewToggle .toggleBtn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 15px; border: none; background-color: transparent; border-radius: 25px; cursor: pointer; font-size: 1rem; font-weight: 500; color: #555; transition: all 0.2s ease-in-out; }
.mobileCardViewToggle .toggleBtn.active { background-color: #007bff; color: white; box-shadow: 0 2px 5px rgba(0, 123, 255, 0.2); }
@media (max-width: 380px) {
.mobileResultsHeader { padding: 4px 10px; }
.mobileResultsHeader .headerInfo h4 { font-size: 0.75rem; text-transform: uppercase; }
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.7rem; }
.mobileCardViewToggle .toggleBtn { padding: 6px 10px; font-size: 0.8rem; }
}

View File

@@ -1,3 +1,5 @@
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
import { useMemo, useState, Suspense, useEffect } from 'react'; import { useMemo, useState, Suspense, useEffect } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { getPanelElectoral } from '../../../apiService'; import { getPanelElectoral } from '../../../apiService';
@@ -5,7 +7,8 @@ import { MapaNacional } from './components/MapaNacional';
import { PanelResultados } from './components/PanelResultados'; import { PanelResultados } from './components/PanelResultados';
import { Breadcrumbs } from './components/Breadcrumbs'; import { Breadcrumbs } from './components/Breadcrumbs';
import { MunicipioSearch } from './components/MunicipioSearch'; import { MunicipioSearch } from './components/MunicipioSearch';
import './PanelNacional.css'; // 1. La importación de CSS ahora se hace como un módulo
import styles from './PanelNacional.module.css';
import Select from 'react-select'; import Select from 'react-select';
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types'; import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi'; import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
@@ -26,22 +29,23 @@ interface MobileResultsCardProps {
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO --- // --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
// 2. Todas las props 'className' ahora usan el objeto 'styles'
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => ( const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
<div className="mobile-result-row" style={{ borderLeftColor: partido.color || '#ccc' }}> <div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}>
<div className="mobile-result-logo" style={{ backgroundColor: partido.color || '#e9ecef' }}> <div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div> </div>
<div className="mobile-result-info"> <div className={styles.mobileResultInfo}>
{partido.nombreCandidato ? ( {partido.nombreCandidato ? (
<> <>
<span className="mobile-result-party-name">{partido.nombreCandidato}</span> <span className={styles.mobileResultPartyName}>{partido.nombreCandidato}</span>
<span className="mobile-result-candidate-name">{partido.nombreCorto || partido.nombre}</span> <span className={styles.mobileResultCandidateName}>{partido.nombreCorto || partido.nombre}</span>
</> </>
) : ( ) : (
<span className="mobile-result-party-name">{partido.nombreCorto || partido.nombre}</span> <span className={styles.mobileResultPartyName}>{partido.nombreCorto || partido.nombre}</span>
)} )}
</div> </div>
<div className="mobile-result-stats"> <div className={styles.mobileResultStats}>
<strong>{formatPercent(partido.porcentaje)}</strong> <strong>{formatPercent(partido.porcentaje)}</strong>
<span>{partido.votos.toLocaleString('es-AR')}</span> <span>{partido.votos.toLocaleString('es-AR')}</span>
</div> </div>
@@ -80,44 +84,50 @@ const MobileResultsCard = ({
return null; return null;
} }
// 3. Clases condicionales también se construyen con el objeto 'styles'
const cardClasses = [
styles.mobileResultsCardContainer,
isExpanded ? styles.expanded : '',
styles[`view-${mobileView}`]
].join(' ');
return ( return (
<div className={`mobile-results-card-container ${isExpanded ? 'expanded' : ''} view-${mobileView}`}> <div className={cardClasses}>
{/* Sección Colapsable con Resultados */} {/* Sección Colapsable con Resultados */}
<div className="collapsible-section"> <div className={styles.collapsibleSection}>
<div className="mobile-results-header" onClick={() => setIsExpanded(!isExpanded)}> <div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}>
<div className="header-info"> <div className={styles.headerInfo}>
<h4>{ambitoNombre}</h4> <h4>{ambitoNombre}</h4>
{/* Se añade una clase para estilizar este texto específicamente */} <span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
<span className="header-action-text">{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
</div> </div>
<div className="header-toggle-icon"> <div className={styles.headerToggleIcon}>
{isExpanded ? <FiChevronDown /> : <FiChevronUp />} {isExpanded ? <FiChevronDown /> : <FiChevronUp />}
</div> </div>
</div> </div>
<div className="mobile-results-content"> <div className={styles.mobileResultsContent}>
{topResults.length > 0 ? ( {topResults.length > 0 ? (
topResults.map(partido => <ResultRow key={partido.id} partido={partido} />) topResults.map(partido => <ResultRow key={partido.id} partido={partido} />)
) : ( ) : (
<p className="no-results-text">No hay resultados para esta selección.</p> <p className={styles.noResultsText}>No hay resultados para esta selección.</p>
)} )}
</div> </div>
</div> </div>
{/* Footer Fijo con Botones de Navegación */} {/* Footer Fijo con Botones de Navegación */}
<div className="mobile-card-view-toggle"> <div className={styles.mobileCardViewToggle}>
<button <button
className={`toggle-btn ${mobileView === 'mapa' ? 'active' : ''}`} className={`${styles.toggleBtn} ${mobileView === 'mapa' ? styles.active : ''}`}
onClick={() => setMobileView('mapa')} onClick={() => setMobileView('mapa')}
> >
<FiMap /> <FiMap />
<span>Mapa</span> <span>Mapa</span>
</button> </button>
<button <button
className={`toggle-btn ${mobileView === 'resultados' ? 'active' : ''}`} className={`${styles.toggleBtn} ${mobileView === 'resultados' ? styles.active : ''}`}
onClick={() => setMobileView('resultados')} onClick={() => setMobileView('resultados')}
> >
<FiList /> <FiList />
<span>Resultados</span> <span>Detalles</span>
</button> </button>
</div> </div>
</div> </div>
@@ -190,22 +200,28 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
[categoriaId] [categoriaId]
); );
const mainContentClasses = [
styles.panelMainContent,
!isPanelOpen ? styles.panelCollapsed : '',
isMobile ? styles[`mobile-view-${mobileView}`] : ''
].join(' ');
return ( return (
<div className="panel-nacional-container"> <div className={styles.panelNacionalContainer}>
<Toaster containerClassName="widget-toaster-container" /> <Toaster containerClassName={styles.widgetToasterContainer} />
<header className="panel-header"> <header className={styles.panelHeader}>
<div className="header-top-row"> <div className={styles.headerTopRow}>
<Select <Select
options={CATEGORIAS_NACIONALES} options={CATEGORIAS_NACIONALES}
value={selectedCategoria} value={selectedCategoria}
onChange={(option) => option && setCategoriaId(option.value)} onChange={(option) => option && setCategoriaId(option.value)}
className="categoria-selector" // 4. Usamos un prefijo de clase simple que se asociará con las clases del módulo CSS
classNamePrefix="categoria-selector" classNamePrefix="categoriaSelector"
className={styles.categoriaSelectorContainer}
isSearchable={false} isSearchable={false}
/> />
</div> </div>
{/* --- 2. NUEVO CONTENEDOR PARA BREADCRUMBS Y BUSCADOR --- */} <div className={styles.headerBottomRow}>
<div className="header-bottom-row">
<Breadcrumbs <Breadcrumbs
nivel={ambitoActual.nivel} nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre} nombreAmbito={ambitoActual.nombre}
@@ -213,7 +229,6 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
onReset={handleResetToPais} onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia} onVolverProvincia={handleVolverAProvincia}
/> />
{/* --- 3. RENDERIZADO CONDICIONAL DEL BUSCADOR --- */}
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && ( {ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
<MunicipioSearch <MunicipioSearch
distritoId={ambitoActual.provinciaDistritoId} distritoId={ambitoActual.provinciaDistritoId}
@@ -224,16 +239,16 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
)} )}
</div> </div>
</header> </header>
<main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''} ${isMobile ? `mobile-view-${mobileView}` : ''}`}> <main className={mainContentClasses}>
<div className="mapa-column"> <div className={styles.mapaColumn}>
<button className="panel-toggle-btn" onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '' : ''} </button> <button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '' : ''} </button>
<Suspense fallback={<div className="spinner" />}> <Suspense fallback={<div className={styles.spinner} />}>
<MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} /> <MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} />
</Suspense> </Suspense>
</div> </div>
<div className="resultados-column"> <div className={styles.resultadosColumn}>
<Suspense fallback={<div className="spinner" />}> <Suspense fallback={<div className={styles.spinner} />}>
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} /> <PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
</Suspense> </Suspense>
</div> </div>

View File

@@ -1,26 +1,28 @@
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.css */ /* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.module.css */
/* --- Variables de Diseño --- */ /* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
:root { .cardsWidgetContainer,
.cardsWidgetContainer * {
font-family: "Roboto", system-ui, sans-serif !important;
box-sizing: border-box;
}
/* --- Contenedor Principal del Widget y Variables --- */
.cardsWidgetContainer {
--card-border-color: #e0e0e0; --card-border-color: #e0e0e0;
--card-bg-color: #ffffff; --card-bg-color: #ffffff;
--card-header-bg-color: #e6f1fd; --card-header-bg-color: #e6f1fd;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); --card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--text-primary: #212529; --text-primary: #212529;
--text-secondary: #6c757d; --text-secondary: #6c757d;
--font-family: "Roboto", system-ui, sans-serif;
--primary-accent-color: #007bff; --primary-accent-color: #007bff;
}
/* --- Contenedor Principal del Widget --- */
.cards-widget-container {
font-family: var(--font-family);
width: 100%; width: 100%;
max-width: 1200px; max-width: 1200px;
margin: 2rem auto; margin: 2rem auto;
} }
.cards-widget-container h2 { .cardsWidgetContainer h2 {
font-size: 1.75rem; font-size: 1.75rem;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@@ -29,27 +31,26 @@
} }
/* --- Grilla de Tarjetas --- */ /* --- Grilla de Tarjetas --- */
.cards-grid { .cardsGrid {
display: grid; display: grid;
/* Crea columnas flexibles que se ajustan al espacio disponible */
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem; gap: 1.5rem;
align-items: start; align-items: start;
} }
/* --- Tarjeta Individual --- */ /* --- Tarjeta Individual --- */
.provincia-card { .provinciaCard {
background-color: var(--card-bg-color); background-color: var(--card-bg-color);
border: 1px solid var(--card-border-color); border: 1px solid var(--card-border-color);
border-radius: 8px; border-radius: 8px;
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; /* Asegura que los bordes redondeados se apliquen al contenido */ overflow: hidden;
} }
/* --- Cabecera de la Tarjeta --- */ /* --- Cabecera de la Tarjeta --- */
.card-header { .cardHeader {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -58,20 +59,20 @@
border-bottom: 1px solid var(--card-border-color); border-bottom: 1px solid var(--card-border-color);
} }
.header-info h3 { .headerInfo h3 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.header-info span { .headerInfo span {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
} }
.header-map { .headerMap {
width: 90px; width: 90px;
height: 90px; height: 90px;
flex-shrink: 0; flex-shrink: 0;
@@ -79,72 +80,65 @@
overflow: hidden; overflow: hidden;
background-color: #f7fbff; background-color: #f7fbff;
padding: 0.25rem; 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 */ .mapSvgContainer, .mapPlaceholder {
.map-svg-container, .map-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 16px; border-radius: 16px;
} }
/* Estilo para el SVG renderizado */ .mapSvgContainer svg {
.map-svg-container svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; /* Asegura que el mapa no se deforme */ object-fit: contain;
} }
/* Placeholder para cuando el mapa no carga */ .mapPlaceholder.error {
.map-placeholder.error { background-color: #f8d7da;
background-color: #f8d7da; /* Un color de fondo rojizo para indicar un error */
} }
/* --- Cuerpo de la Tarjeta --- */ /* --- Cuerpo de la Tarjeta --- */
.card-body { .cardBody {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.candidato-row:last-child { .candidatoRow:last-child {
border-bottom: none; border-bottom: none;
} }
.candidato-row { .candidatoRow {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 0.75rem 0; padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
border-left: 5px solid; /* Grosor del borde */ border-left: 5px solid;
border-radius: 12px; /* Redondeamos las esquinas para un look más suave */ border-radius: 12px;
padding-left: 0.75rem; /* Añadimos un poco de espacio a la izquierda */ padding-left: 0.75rem;
} }
/* Nuevo contenedor para el logo con fondo de color */ .candidatoFotoWrapper {
.candidato-foto-wrapper {
width: 60px; width: 60px;
height: 60px; height: 60px;
border-radius: 12px; border-radius: 12px;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box; background-color: #e9ecef;
background-color: #e9ecef; /* Color de fallback */
} }
/* La imagen ahora llena su nuevo contenedor */ .candidatoFoto {
.candidato-foto {
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 12px; border-radius: 12px;
} }
.candidato-data { .candidatoData {
flex-grow: 1; flex-grow: 1;
min-width: 0; /* Permite que el texto se trunque si es necesario */ min-width: 0;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.candidato-nombre { .candidatoNombre {
font-weight: 700; font-weight: 700;
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-primary); color: var(--text-primary);
@@ -152,7 +146,7 @@
text-align: left; text-align: left;
} }
.candidato-partido { .candidatoPartido {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
@@ -161,20 +155,20 @@
text-align: left; text-align: left;
} }
.progress-bar-container { .progressBarContainer {
height: 16px; height: 16px;
background-color: #e9ecef; background-color: #e9ecef;
border-radius: 3px; border-radius: 3px;
overflow: hidden; overflow: hidden;
} }
.progress-bar { .progressBar {
height: 100%; height: 100%;
border-radius: 3px; border-radius: 3px;
transition: width 0.5s ease-out; transition: width 0.5s ease-out;
} }
.candidato-stats { .candidatoStats {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
@@ -183,18 +177,18 @@
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.stats-percent { .statsPercent {
font-weight: 700; font-weight: 700;
font-size: 1.1rem; font-size: 1.1rem;
color: var(--text-primary); color: var(--text-primary);
} }
.stats-votos { .statsVotos {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.stats-bancas { .statsBancas {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -210,7 +204,7 @@
min-width: 50px; min-width: 50px;
} }
.stats-bancas span { .statsBancas span {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
@@ -218,9 +212,8 @@
margin-top: -4px; margin-top: -4px;
} }
/* --- Pie de la Tarjeta --- */ /* --- Pie de la Tarjeta --- */
.card-footer { .cardFooter {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
background-color: var(--card-header-bg-color); background-color: var(--card-header-bg-color);
@@ -229,21 +222,21 @@
text-align: center; text-align: center;
} }
.card-footer div { .cardFooter div {
border-right: 1px solid var(--card-border-color); border-right: 1px solid var(--card-border-color);
} }
.card-footer div:last-child { .cardFooter div:last-child {
border-right: none; border-right: none;
} }
.card-footer span { .cardFooter span {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
.card-footer strong { .cardFooter strong {
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
@@ -251,43 +244,39 @@
/* --- Media Query para Móvil --- */ /* --- Media Query para Móvil --- */
@media (max-width: 480px) { @media (max-width: 480px) {
.cards-grid { .cardsGrid {
/* En pantallas muy pequeñas, forzamos una sola columna */
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.card-header { .cardHeader {
padding: 0.5rem; padding: 0.5rem;
} }
.header-info h3 { .headerInfo h3 {
font-size: 1rem; font-size: 1rem;
} }
} }
/* --- ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */ .candidatoPartido.mainTitle {
.candidato-partido.main-title { font-size: 0.95rem;
font-size: 0.95rem; /* Hacemos la fuente más grande */ font-weight: 700;
font-weight: 700; /* La ponemos en negrita, como el nombre del candidato */ color: var(--text-primary);
color: var(--text-primary); /* Usamos el color de texto principal */ text-transform: none;
text-transform: none; /* Quitamos el 'uppercase' para que se lea mejor */
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
/* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */ /* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */
.categoriaBloque {
.categoria-bloque {
width: 100%; width: 100%;
} }
/* Añadimos un separador si hay más de una categoría en la misma tarjeta */ .categoriaBloque + .categoriaBloque {
.categoria-bloque + .categoria-bloque {
border-top: 1px dashed var(--card-border-color); border-top: 1px dashed var(--card-border-color);
margin-top: 1rem; margin-top: 1rem;
padding-top: 1rem; padding-top: 1rem;
} }
.categoria-titulo { .categoriaTitulo {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
@@ -295,19 +284,18 @@
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
} }
/* Ajuste para el footer, que ahora está dentro de cada categoría */ .categoriaBloque .cardFooter {
.categoria-bloque .card-footer {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
background-color: transparent; /* Quitamos el fondo gris */ background-color: transparent;
border-top: 1px solid var(--card-border-color); border-top: 1px solid var(--card-border-color);
padding: 0.75rem 0; padding: 0.75rem 0;
margin-top: 0.75rem; /* Espacio antes del footer */ margin-top: 0.75rem;
text-align: center; text-align: center;
} }
.categoria-bloque .card-footer div { .categoriaBloque .cardFooter div {
border-right: 1px solid var(--card-border-color); border-right: 1px solid var(--card-border-color);
} }
.categoria-bloque .card-footer div:last-child { .categoriaBloque .cardFooter div:last-child {
border-right: none; border-right: none;
} }

View File

@@ -2,24 +2,23 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getResumenPorProvincia } from '../../../apiService'; import { getResumenPorProvincia } from '../../../apiService';
import { ProvinciaCard } from './components/ProvinciaCard'; import { ProvinciaCard } from './components/ProvinciaCard';
import './ResultadosNacionalesCardsWidget.css'; // 1. La importación de CSS ahora se hace como un módulo
import styles from './ResultadosNacionalesCardsWidget.module.css';
// --- 1. AÑADIR LA PROP A LA INTERFAZ ---
interface Props { interface Props {
eleccionId: number; eleccionId: number;
focoDistritoId?: string; focoDistritoId?: string;
focoCategoriaId?: number; focoCategoriaId?: number;
cantidadResultados?: number; cantidadResultados?: number;
mostrarBancas?: boolean; // Booleano opcional mostrarBancas?: boolean;
} }
// --- 2. RECIBIR LA PROP Y ESTABLECER UN VALOR POR DEFECTO ---
export const ResultadosNacionalesCardsWidget = ({ export const ResultadosNacionalesCardsWidget = ({
eleccionId, eleccionId,
focoDistritoId, focoDistritoId,
focoCategoriaId, focoCategoriaId,
cantidadResultados, cantidadResultados,
mostrarBancas = false // Por defecto, no se muestran las bancas mostrarBancas = false
}: Props) => { }: Props) => {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
@@ -36,9 +35,10 @@ export const ResultadosNacionalesCardsWidget = ({
if (error) return <div>Error al cargar los datos.</div>; if (error) return <div>Error al cargar los datos.</div>;
if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div> if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div>
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return ( return (
<section className="cards-widget-container"> <section className={styles.cardsWidgetContainer}>
<div className="cards-grid"> <div className={styles.cardsGrid}>
{data?.map(provinciaData => ( {data?.map(provinciaData => (
<ProvinciaCard <ProvinciaCard
key={provinciaData.provinciaId} key={provinciaData.provinciaId}

View File

@@ -1,5 +1,7 @@
// src/features/legislativas/nacionales/components/Breadcrumbs.tsx // src/features/legislativas/nacionales/components/Breadcrumbs.tsx
import { FiHome, FiChevronRight } from 'react-icons/fi'; import { FiHome, FiChevronRight } from 'react-icons/fi';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
interface BreadcrumbsProps { interface BreadcrumbsProps {
nivel: 'pais' | 'provincia' | 'municipio'; nivel: 'pais' | 'provincia' | 'municipio';
@@ -10,36 +12,37 @@ interface BreadcrumbsProps {
} }
export const Breadcrumbs = ({ nivel, nombreAmbito, nombreProvincia, onReset, onVolverProvincia }: BreadcrumbsProps) => { export const Breadcrumbs = ({ nivel, nombreAmbito, nombreProvincia, onReset, onVolverProvincia }: BreadcrumbsProps) => {
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return ( return (
<nav className="breadcrumbs-container"> <nav className={styles.breadcrumbsContainer}>
{nivel !== 'pais' ? ( {nivel !== 'pais' ? (
<> <>
<button onClick={onReset} className="breadcrumb-item"> <button onClick={onReset} className={styles.breadcrumbItem}>
<FiHome className="breadcrumb-icon" /> <FiHome className={styles.breadcrumbIcon} />
<span>Argentina</span> <span>Argentina</span>
</button> </button>
<FiChevronRight className="breadcrumb-separator" /> <FiChevronRight className={styles.breadcrumbSeparator} />
</> </>
) : ( ) : (
<div className="breadcrumb-item-actual"> <div className={styles.breadcrumbItemActual}>
<FiHome className="breadcrumb-icon" /> <FiHome className={styles.breadcrumbIcon} />
<span>{nombreAmbito}</span> <span>{nombreAmbito}</span>
</div> </div>
)} )}
{nivel === 'provincia' && ( {nivel === 'provincia' && (
<div className="breadcrumb-item-actual"> <div className={styles.breadcrumbItemActual}>
<span>{nombreAmbito}</span> <span>{nombreAmbito}</span>
</div> </div>
)} )}
{nivel === 'municipio' && nombreProvincia && ( {nivel === 'municipio' && nombreProvincia && (
<> <>
<button onClick={onVolverProvincia} className="breadcrumb-item"> <button onClick={onVolverProvincia} className={styles.breadcrumbItem}>
<span>{nombreProvincia}</span> <span>{nombreProvincia}</span>
</button> </button>
<FiChevronRight className="breadcrumb-separator" /> <FiChevronRight className={styles.breadcrumbSeparator} />
<div className="breadcrumb-item-actual"> <div className={styles.breadcrumbItemActual}>
<span>{nombreAmbito}</span> <span>{nombreAmbito}</span>
</div> </div>
</> </>

View File

@@ -1,17 +1,21 @@
// src/features/legislativas/nacionales/components/CabaLupa.tsx // src/features/legislativas/nacionales/components/CabaLupa.tsx
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
interface CabaLupaProps { interface CabaLupaProps {
fillColor: string; fillColor: string;
onClick: () => void; onClick: () => void;
} }
export const CabaLupa = ({ fillColor, onClick }: CabaLupaProps) => { export const CabaLupa = ({ fillColor, onClick }: CabaLupaProps) => {
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return ( return (
<svg viewBox="0 0 542 256" className="caba-lupa-svg"> <svg viewBox="0 0 542 256" className={styles.cabaLupaSvg}>
<g transform="translate(10, 10)"> <g transform="translate(10, 10)">
<g <g
onClick={onClick} onClick={onClick}
className="caba-lupa-interactive-area" className={styles.cabaLupaInteractiveArea}
> >
<g transform="rotate(25.8155, 280.549, 151.582)"> <g transform="rotate(25.8155, 280.549, 151.582)">
<ellipse ry="116.11771" rx="123.49187" cy="69.21453" cx="372.69183" stroke="#999" strokeWidth="2" fill="#fff" /> <ellipse ry="116.11771" rx="123.49187" cy="69.21453" cx="372.69183" stroke="#999" strokeWidth="2" fill="#fff" />

View File

@@ -1,3 +1,4 @@
// src/features/legislativas/nacionales/components/MapaNacional.tsx
import axios from 'axios'; import axios from 'axios';
import { Suspense, useState, useEffect, useCallback, useRef } from 'react'; import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
@@ -12,6 +13,8 @@ import { CabaLupa } from './CabaLupa';
import { BiZoomIn, BiZoomOut } from "react-icons/bi"; import { BiZoomIn, BiZoomOut } from "react-icons/bi";
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useMediaQuery } from '../hooks/useMediaQuery'; import { useMediaQuery } from '../hooks/useMediaQuery';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
const DEFAULT_MAP_COLOR = '#E0E0E0'; const DEFAULT_MAP_COLOR = '#E0E0E0';
const FADED_BACKGROUND_COLOR = '#F0F0F0'; const FADED_BACKGROUND_COLOR = '#F0F0F0';
@@ -52,7 +55,6 @@ interface MapaNacionalProps {
isMobileView: boolean; isMobileView: boolean;
} }
// --- CONFIGURACIONES DEL MAPA ---
const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] }; const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] };
const mobileProjectionConfig = { scale: 1000, center: [-64, -43] as [number, number] }; const mobileProjectionConfig = { scale: 1000, center: [-64, -43] as [number, number] };
const mobileSmallProjectionConfig = { scale: 750, center: [-64, -45] as [number, number] }; const mobileSmallProjectionConfig = { scale: 750, center: [-64, -45] as [number, number] };
@@ -123,7 +125,6 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
} }
}, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]); }, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]);
const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d])); const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d]));
const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null; const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null;
const handleCalculatedCenter = useCallback((center: PointTuple, zoom: number) => { setPosition({ center, zoom }); }, []); const handleCalculatedCenter = useCallback((center: PointTuple, zoom: number) => { setPosition({ center, zoom }); }, []);
@@ -236,29 +237,30 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
(nivel === 'provincia' && initialProvincePositionRef.current && position.zoom <= initialProvincePositionRef.current.zoom) || (nivel === 'provincia' && initialProvincePositionRef.current && position.zoom <= initialProvincePositionRef.current.zoom) ||
(nivel === 'pais' && position.zoom <= (isMobileView ? 1.4 : 1.05)); (nivel === 'pais' && position.zoom <= (isMobileView ? 1.4 : 1.05));
const mapContainerClasses = panEnabled ? 'mapa-componente-container map-pannable' : 'mapa-componente-container map-locked'; // 2. Todas las props 'className' ahora usan el objeto 'styles'
const mapContainerClasses = `${styles.mapaComponenteContainer} ${panEnabled ? styles.mapPannable : styles.mapLocked}`;
return ( return (
<div className={mapContainerClasses} ref={containerRef}> <div className={mapContainerClasses} ref={containerRef}>
{showZoomControls && ( {showZoomControls && (
<div className="zoom-controls-container"> <div className={styles.zoomControlsContainer}>
<button onClick={handleZoomIn} className="zoom-btn" title="Acercar"> <button onClick={handleZoomIn} className={styles.zoomBtn} title="Acercar">
<span className="zoom-icon-wrapper"><BiZoomIn /></span> <span className={styles.zoomIconWrapper}><BiZoomIn /></span>
</button> </button>
<button <button
onClick={handleZoomOut} onClick={handleZoomOut}
className={`zoom-btn ${isZoomOutDisabled ? 'disabled' : ''}`} className={`${styles.zoomBtn} ${isZoomOutDisabled ? styles.disabled : ''}`}
title="Alejar" title="Alejar"
disabled={isZoomOutDisabled} disabled={isZoomOutDisabled}
> >
<span className="zoom-icon-wrapper"><BiZoomOut /></span> <span className={styles.zoomIconWrapper}><BiZoomOut /></span>
</button> </button>
</div> </div>
)} )}
{nivel !== 'pais' && <button onClick={onVolver} className="mapa-volver-btn"> Volver</button>} {nivel !== 'pais' && <button onClick={onVolver} className={styles.mapaVolverBtn}> Volver</button>}
<div className="mapa-render-area"> <div className={styles.mapaRenderArea}>
<ComposableMap <ComposableMap
projection="geoMercator" projection="geoMercator"
projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)} projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)}
@@ -270,7 +272,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
onMoveStart={() => setIsPanning(true)} onMoveStart={() => setIsPanning(true)}
onMoveEnd={handleMoveEnd} onMoveEnd={handleMoveEnd}
filterZoomEvent={filterInteractionEvents} filterZoomEvent={filterInteractionEvents}
className={isPanning ? 'panning' : ''} className={isPanning ? styles.panning : ''}
> >
<Geographies geography={geoDataNacional}> <Geographies geography={geoDataNacional}>
{({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => { {({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => {
@@ -284,6 +286,8 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
key={geo.rsmKey} key={geo.rsmKey}
geography={geo} geography={geo}
ref={esCABA ? cabaPathRef : undefined} ref={esCABA ? cabaPathRef : undefined}
// 3. Las clases de react-simple-maps ahora deben ser globales o no funcionarán
// Como el CSS module ya las define con :global(), no necesitamos hacer nada aquí.
className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`} className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`}
style={{ visibility: esCABA ? 'hidden' : (esProvinciaActiva ? 'hidden' : 'visible') }} style={{ visibility: esCABA ? 'hidden' : (esProvinciaActiva ? 'hidden' : 'visible') }}
fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR} fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR}
@@ -314,7 +318,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
</div> </div>
{nivel === 'pais' && ( {nivel === 'pais' && (
<div id="caba-lupa-anchor" className="caba-magnifier-container" style={lupaStyle} ref={lupaRef}> <div id="caba-lupa-anchor" className={styles.cabaMagnifierContainer} style={lupaStyle} ref={lupaRef}>
{(() => { {(() => {
const resultadoCABA = resultadosNacionalesPorNombre.get("CIUDAD AUTONOMA DE BUENOS AIRES"); const resultadoCABA = resultadosNacionalesPorNombre.get("CIUDAD AUTONOMA DE BUENOS AIRES");
const fillColor = resultadoCABA?.colorGanador || DEFAULT_MAP_COLOR; const fillColor = resultadoCABA?.colorGanador || DEFAULT_MAP_COLOR;

View File

@@ -7,6 +7,9 @@ import { geoCentroid } from 'd3-geo';
import { feature } from 'topojson-client'; import { feature } from 'topojson-client';
import { API_BASE_URL, assetBaseUrl } from '../../../../apiService'; import { API_BASE_URL, assetBaseUrl } from '../../../../apiService';
import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'; import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types';
// 1. A diferencia de otros componentes, este no necesita importar el CSS
// porque no tiene un contenedor propio ni clases únicas.
// Heredará y usará las clases globales (:global) definidas en PanelNacional.module.css
const DEFAULT_MAP_COLOR = '#E0E0E0'; const DEFAULT_MAP_COLOR = '#E0E0E0';
const normalizarTexto = (texto: string = ''): string => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""); const normalizarTexto = (texto: string = ''): string => texto.trim().toUpperCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
@@ -42,7 +45,6 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
}, },
}); });
// useEffect que calcula y exporta la posición del municipio al padre
useEffect(() => { useEffect(() => {
if (nivel === 'municipio' && geoData?.objects && nombreMunicipioSeleccionado) { if (nivel === 'municipio' && geoData?.objects && nombreMunicipioSeleccionado) {
const geometries = geoData.objects[Object.keys(geoData.objects)[0]].geometries; const geometries = geoData.objects[Object.keys(geoData.objects)[0]].geometries;
@@ -50,7 +52,6 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
if (municipioGeo) { if (municipioGeo) {
const municipioFeature = feature(geoData, municipioGeo); const municipioFeature = feature(geoData, municipioGeo);
const centroid = geoCentroid(municipioFeature); const centroid = geoCentroid(municipioFeature);
// Llama a la función del padre para que actualice la posición
onCalculatedCenter(centroid as PointTuple, 40); onCalculatedCenter(centroid as PointTuple, 40);
} }
} }
@@ -65,6 +66,9 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
const resultado = resultadosPorNombre.get(normalizarTexto(geo.properties.departamento)); const resultado = resultadosPorNombre.get(normalizarTexto(geo.properties.departamento));
const esSeleccionado = nombreMunicipioSeleccionado ? normalizarTexto(geo.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado) : false; const esSeleccionado = nombreMunicipioSeleccionado ? normalizarTexto(geo.properties.departamento) === normalizarTexto(nombreMunicipioSeleccionado) : false;
// 2. Las clases aquí NO usan el objeto 'styles' porque son clases
// que react-simple-maps necesita globalmente. El archivo CSS
// ya se encarga de estilizarlas usando :global(.rsm-geography), etc.
const classNames = [ const classNames = [
'rsm-geography', 'rsm-geography',
'mapa-provincial-geography', 'mapa-provincial-geography',

View File

@@ -3,61 +3,54 @@ import { useQuery } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { assetBaseUrl } from '../../../../apiService'; import { assetBaseUrl } from '../../../../apiService';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../ResultadosNacionalesCardsWidget.module.css';
interface MiniMapaSvgProps { interface MiniMapaSvgProps {
provinciaNombre: string; provinciaNombre: string;
fillColor: 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) => const normalizarNombreParaUrl = (nombre: string) =>
nombre nombre
.toLowerCase() .toLowerCase()
.replace(/ /g, '_') // Reemplaza espacios con guiones bajos .replace(/ /g, '_')
.normalize("NFD") // Descompone acentos para eliminarlos en el siguiente paso .normalize("NFD")
.replace(/[\u0300-\u036f]/g, ""); // Elimina los acentos .replace(/[\u0300-\u036f]/g, "");
export const MiniMapaSvg = ({ provinciaNombre, fillColor }: MiniMapaSvgProps) => { export const MiniMapaSvg = ({ provinciaNombre, fillColor }: MiniMapaSvgProps) => {
const nombreNormalizado = normalizarNombreParaUrl(provinciaNombre); const nombreNormalizado = normalizarNombreParaUrl(provinciaNombre);
// Asumimos que los SVGs están en /public/maps/provincias-svg/
const mapFileUrl = `${assetBaseUrl}/maps/provincias-svg/${nombreNormalizado}.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>({ const { data: svgContent, isLoading, isError } = useQuery<string>({
queryKey: ['svgMapa', nombreNormalizado], queryKey: ['svgMapa', nombreNormalizado],
queryFn: async () => { queryFn: async () => {
const response = await axios.get(mapFileUrl, { responseType: 'text' }); const response = await axios.get(mapFileUrl, { responseType: 'text' });
return response.data; return response.data;
}, },
staleTime: Infinity, // Estos archivos son estáticos y no cambian staleTime: Infinity,
gcTime: Infinity, gcTime: Infinity,
retry: false, // No reintentar si el archivo no existe retry: false,
}); });
// Usamos useMemo para modificar el SVG solo cuando el contenido o el color cambian
const modifiedSvg = useMemo(() => { const modifiedSvg = useMemo(() => {
if (!svgContent) return ''; 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}"`); return svgContent.replace(/<path/g, `<path fill="${fillColor}"`);
}, [svgContent, fillColor]); }, [svgContent, fillColor]);
if (isLoading) { if (isLoading) {
return <div className="map-placeholder" />; // 2. Usamos el objeto 'styles' para las clases
return <div className={styles.mapPlaceholder} />;
} }
if (isError || !modifiedSvg) { if (isError || !modifiedSvg) {
// Muestra un placeholder si el SVG no se encontró o está vacío // 3. Combinamos clases del módulo para el estado de error
return <div className="map-placeholder error" />; return <div className={`${styles.mapPlaceholder} ${styles.error}`} />;
} }
// Renderizamos el SVG modificado. dangerouslySetInnerHTML es seguro aquí
// porque el contenido proviene de nuestros propios archivos SVG estáticos.
return ( return (
<div <div
className="map-svg-container" className={styles.mapSvgContainer}
dangerouslySetInnerHTML={{ __html: modifiedSvg }} dangerouslySetInnerHTML={{ __html: modifiedSvg }}
/> />
); );

View File

@@ -4,6 +4,8 @@ import { useQuery } from '@tanstack/react-query';
import Select, { type SingleValue } from 'react-select'; import Select, { type SingleValue } from 'react-select';
import { getMunicipiosPorDistrito } from '../../../../apiService'; import { getMunicipiosPorDistrito } from '../../../../apiService';
import type { CatalogoItem } from '../../../../types/types'; import type { CatalogoItem } from '../../../../types/types';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
interface MunicipioSearchProps { interface MunicipioSearchProps {
distritoId: string; distritoId: string;
@@ -15,20 +17,9 @@ interface OptionType {
label: string; label: string;
} }
const customSelectStyles = { // 2. Definimos los estilos custom para react-select.
control: (base: any) => ({ // Ya no son necesarios aquí porque los definimos en el CSS module.
...base, // const customSelectStyles: StylesConfig<OptionType, false> = { ... };
borderRadius: '8px',
borderColor: '#e0e0e0',
boxShadow: 'none',
'&:hover': { borderColor: '#007bff' }
}),
menu: (base: any) => ({
...base,
borderRadius: '8px',
zIndex: 30
})
};
export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSearchProps) => { export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSearchProps) => {
const [selectedOption, setSelectedOption] = useState<SingleValue<OptionType>>(null); const [selectedOption, setSelectedOption] = useState<SingleValue<OptionType>>(null);
@@ -47,12 +38,15 @@ export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSear
const handleChange = (selected: SingleValue<OptionType>) => { const handleChange = (selected: SingleValue<OptionType>) => {
if (selected) { if (selected) {
onMunicipioSelect(selected.value, selected.label); onMunicipioSelect(selected.value, selected.label);
setSelectedOption(null); // Resetea el selector para que muestre el placeholder de nuevo setSelectedOption(null);
} }
}; };
// 3. Aplicamos la clase del módulo al contenedor principal
// y usamos el classNamePrefix para que react-select genere clases
// que podamos estilizar desde el CSS module.
return ( return (
<div className="municipio-search-container"> <div className={styles.municipioSearchContainer}>
<Select <Select
options={options} options={options}
onChange={handleChange} onChange={handleChange}
@@ -61,7 +55,9 @@ export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSear
placeholder="Buscar municipio..." placeholder="Buscar municipio..."
isClearable isClearable
isSearchable isSearchable
styles={customSelectStyles} // El prefijo se mantiene igual al del selector de categorías.
// Esto nos permite reutilizar los mismos estilos :global que ya definimos.
classNamePrefix="categoriaSelector"
noOptionsMessage={() => 'No se encontraron municipios'} noOptionsMessage={() => 'No se encontraron municipios'}
/> />
</div> </div>

View File

@@ -5,6 +5,8 @@ import { assetBaseUrl } from '../../../../apiService';
import { AnimatedNumber } from './AnimatedNumber'; import { AnimatedNumber } from './AnimatedNumber';
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'; import { CircularProgressbar, buildStyles } from 'react-circular-progressbar';
import 'react-circular-progressbar/dist/styles.css'; import 'react-circular-progressbar/dist/styles.css';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR'); const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR');
@@ -30,11 +32,12 @@ interface PanelResultadosProps {
} }
export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosProps) => { export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosProps) => {
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return ( return (
<div className="panel-resultados"> <div className={styles.panelResultados}>
<SvgDefs /> <SvgDefs />
<div className="panel-estado-recuento"> <div className={styles.panelEstadoRecuento}>
<div className="estado-item"> <div className={styles.estadoItem}>
<CircularProgressbar <CircularProgressbar
value={estadoRecuento.participacionPorcentaje} value={estadoRecuento.participacionPorcentaje}
text={formatPercent(estadoRecuento.participacionPorcentaje)} text={formatPercent(estadoRecuento.participacionPorcentaje)}
@@ -50,7 +53,7 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
/> />
<span>Participación</span> <span>Participación</span>
</div> </div>
<div className="estado-item"> <div className={styles.estadoItem}>
<CircularProgressbar <CircularProgressbar
value={estadoRecuento.mesasTotalizadasPorcentaje} value={estadoRecuento.mesasTotalizadasPorcentaje}
text={formatPercent(estadoRecuento.mesasTotalizadasPorcentaje)} text={formatPercent(estadoRecuento.mesasTotalizadasPorcentaje)}
@@ -68,39 +71,39 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
</div> </div>
</div> </div>
<div className="panel-partidos-container"> <div className={styles.panelPartidosContainer}>
{resultados.map(partido => ( {resultados.map(partido => (
<div <div
key={partido.id} key={partido.id}
className="partido-fila" className={styles.partidoFila}
style={{ borderLeftColor: partido.color || '#ccc' }} style={{ borderLeftColor: partido.color || '#ccc' }}
> >
<div className="partido-logo" style={{ backgroundColor: partido.color || '#e9ecef' }}> <div className={styles.partidoLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} /> <ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div> </div>
<div className="partido-main-content"> <div className={styles.partidoMainContent}>
<div className="partido-top-row"> <div className={styles.partidoTopRow}>
<div className="partido-info-wrapper"> <div className={styles.partidoInfoWrapper}>
{partido.nombreCandidato ? ( {partido.nombreCandidato ? (
<> <>
<span className="candidato-nombre">{partido.nombreCandidato}</span> <span className={styles.candidatoNombre}>{partido.nombreCandidato}</span>
<span className="partido-nombre-normal">{partido.nombreCorto || partido.nombre}</span> <span className={styles.partidoNombreNormal}>{partido.nombreCorto || partido.nombre}</span>
</> </>
) : ( ) : (
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span> <span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
)} )}
</div> </div>
<div className="partido-stats"> <div className={styles.partidoStats}>
<span className="partido-porcentaje"> <span className={styles.partidoPorcentaje}>
<AnimatedNumber value={partido.porcentaje} formatter={formatPercent} /> <AnimatedNumber value={partido.porcentaje} formatter={formatPercent} />
</span> </span>
<span className="partido-votos"> <span className={styles.partidoVotos}>
<AnimatedNumber value={partido.votos} formatter={formatVotes} /> votos <AnimatedNumber value={partido.votos} formatter={formatVotes} /> votos
</span> </span>
</div> </div>
</div> </div>
<div className="partido-barra-background"> <div className={styles.partidoBarraBackground}>
<div className="partido-barra-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }} /> <div className={styles.partidoBarraForeground} style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,26 @@
// src/features/legislativas/nacionales/components/PanelResultadosSkeleton.tsx // src/features/legislativas/nacionales/components/PanelResultadosSkeleton.tsx
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../PanelNacional.module.css';
// 2. Todas las props 'className' ahora usan el objeto 'styles'
const SkeletonRow = () => ( const SkeletonRow = () => (
<div className="partido-fila skeleton-fila"> <div className={`${styles.partidoFila} ${styles.skeletonFila}`}>
<div className="skeleton-logo" /> <div className={styles.skeletonLogo} />
<div className="partido-info-wrapper"> <div className={styles.partidoInfoWrapper}>
<div className="skeleton-text" style={{ width: '60%' }} /> <div className={styles.skeletonText} style={{ width: '60%' }} />
<div className="skeleton-text" style={{ width: '40%', marginTop: '4px' }} /> <div className={styles.skeletonText} style={{ width: '40%', marginTop: '4px' }} />
<div className="skeleton-bar" /> <div className={styles.skeletonBar} />
</div> </div>
<div className="partido-stats"> <div className={styles.partidoStats}>
<div className="skeleton-text" style={{ width: '70%', marginBottom: '4px' }} /> <div className={styles.skeletonText} style={{ width: '70%', marginBottom: '4px' }} />
<div className="skeleton-text" style={{ width: '50%' }} /> <div className={styles.skeletonText} style={{ width: '50%' }} />
</div> </div>
</div> </div>
); );
export const PanelResultadosSkeleton = () => ( export const PanelResultadosSkeleton = () => (
<div className="panel-resultados-skeleton"> <div className={styles.panelResultadosSkeleton}>
{[...Array(5)].map((_, i) => <SkeletonRow key={i} />)} {[...Array(5)].map((_, i) => <SkeletonRow key={i} />)}
</div> </div>
); );

View File

@@ -3,6 +3,8 @@ import type { ResumenProvincia, CategoriaResumen } from '../../../../types/types
import { MiniMapaSvg } from './MiniMapaSvg'; import { MiniMapaSvg } from './MiniMapaSvg';
import { ImageWithFallback } from '../../../../components/common/ImageWithFallback'; import { ImageWithFallback } from '../../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../../apiService'; import { assetBaseUrl } from '../../../../apiService';
// 1. Importamos el archivo de estilos como un módulo CSS
import styles from '../ResultadosNacionalesCardsWidget.module.css';
interface CategoriaDisplayProps { interface CategoriaDisplayProps {
categoria: CategoriaResumen; categoria: CategoriaResumen;
@@ -17,48 +19,47 @@ interface ProvinciaCardProps {
const formatNumber = (num: number) => num.toLocaleString('es-AR'); const formatNumber = (num: number) => num.toLocaleString('es-AR');
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`; const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
// 2. Todas las props 'className' en este sub-componente ahora usan el objeto 'styles'
const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => { const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => {
return ( return (
<div className="categoria-bloque"> <div className={styles.categoriaBloque}>
<h4 className="categoria-titulo">{categoria.categoriaNombre}</h4> <h4 className={styles.categoriaTitulo}>{categoria.categoriaNombre}</h4>
{categoria.resultados.map(res => ( {categoria.resultados.map(res => (
<div <div
key={res.agrupacionId} key={res.agrupacionId}
className="candidato-row" className={styles.candidatoRow}
style={{ borderLeftColor: res.color || '#ccc' }} style={{ borderLeftColor: res.color || '#ccc' }}
> >
{/* --- INICIO DE LA MODIFICACIÓN --- */} <div className={styles.candidatoFotoWrapper} style={{ backgroundColor: res.color || '#e9ecef' }}>
<div className="candidato-foto-wrapper" style={{ backgroundColor: res.color || '#e9ecef' }}>
<ImageWithFallback <ImageWithFallback
src={res.fotoUrl ?? undefined} src={res.fotoUrl ?? undefined}
fallbackSrc={`${assetBaseUrl}/default-avatar.png`} fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
alt={res.nombreCandidato ?? res.nombreAgrupacion} alt={res.nombreCandidato ?? res.nombreAgrupacion}
className="candidato-foto" className={styles.candidatoFoto}
/> />
</div> </div>
{/* --- FIN DE LA MODIFICACIÓN --- */}
<div className="candidato-data"> <div className={styles.candidatoData}>
{res.nombreCandidato ? ( {res.nombreCandidato ? (
<> <>
<span className="candidato-nombre">{res.nombreCandidato}</span> <span className={styles.candidatoNombre}>{res.nombreCandidato}</span>
<span className="candidato-partido">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span> <span className={styles.candidatoPartido}>{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
</> </>
) : ( ) : (
<span className="candidato-nombre">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span> <span className={styles.candidatoNombre}>{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
)} )}
<div className="progress-bar-container"> <div className={styles.progressBarContainer}>
<div className="progress-bar" style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} /> <div className={styles.progressBar} style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} />
</div> </div>
</div> </div>
<div className="candidato-stats"> <div className={styles.candidatoStats}>
<span className="stats-percent">{formatPercent(res.porcentaje)}</span> <span className={styles.statsPercent}>{formatPercent(res.porcentaje)}</span>
<span className="stats-votos">{formatNumber(res.votos)} votos</span> <span className={styles.statsVotos}>{formatNumber(res.votos)} votos</span>
</div> </div>
{mostrarBancas && ( {mostrarBancas && (
<div className="stats-bancas"> <div className={styles.statsBancas}>
+{res.bancasObtenidas} +{res.bancasObtenidas}
<span>Bancas</span> <span>Bancas</span>
</div> </div>
@@ -66,7 +67,7 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
</div> </div>
))} ))}
<footer className="card-footer"> <footer className={styles.cardFooter}>
<div> <div>
<span>Participación</span> <span>Participación</span>
<strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong> <strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong>
@@ -87,17 +88,18 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => { export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => {
const colorGanador = data.categorias[0]?.resultados[0]?.color || '#d1d1d1'; const colorGanador = data.categorias[0]?.resultados[0]?.color || '#d1d1d1';
// 3. Y también las clases del componente principal
return ( return (
<div className="provincia-card"> <div className={styles.provinciaCard}>
<header className="card-header"> <header className={styles.cardHeader}>
<div className="header-info"> <div className={styles.headerInfo}>
<h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3> <h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3>
</div> </div>
<div className="header-map"> <div className={styles.headerMap}>
<MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} /> <MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} />
</div> </div>
</header> </header>
<div className="card-body"> <div className={styles.cardBody}>
{data.categorias.map(categoria => ( {data.categorias.map(categoria => (
<CategoriaDisplay <CategoriaDisplay
key={categoria.categoriaId} key={categoria.categoriaId}

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1719e79723abcbe8891a67b4d447a068c41d9e57")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+fa261ba828ec1118d196055b11685d63154ca40d")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]