@@ -13,9 +13,10 @@ 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' ;
import { useMediaQuery } from './hooks/useMediaQuery' ;
import { useMediaQuery } from './hooks/useMediaQuery' ;
import { Toaster } from 'react-hot-toast' ;
import toast , { Toaster } from 'react-hot-toast' ;
import { ImageWithFallback } from '../../../components/common/ImageWithFallback' ;
import { ImageWithFallback } from '../../../components/common/ImageWithFallback' ;
import { assetBaseUrl } from '../../../apiService' ;
import { assetBaseUrl } from '../../../apiService' ;
import { useQueryClient } from '@tanstack/react-query' ;
// --- COMPONENTE INTERNO PARA LA TARJETA DE RESULTADOS EN MÓVIL ---
// --- COMPONENTE INTERNO PARA LA TARJETA DE RESULTADOS EN MÓVIL ---
interface MobileResultsCardProps {
interface MobileResultsCardProps {
@@ -31,25 +32,25 @@ 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'
// 2. Todas las props 'className' ahora usan el objeto 'styles'
const ResultRow = ( { partido } : { partido : ResultadoTicker } ) = > (
const ResultRow = ( { partido } : { partido : ResultadoTicker } ) = > (
< div className = { styles . mobileResultRow } style = { { borderLeftColor : partido.color || '#ccc' } } >
< div className = { styles . mobileResultRow } style = { { borderLeftColor : partido.color || '#ccc' } } >
< div className = { styles . mobileResultLogo } 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 className = { styles . mobileResultInfo } >
{ partido . nombreCandidato ? (
< >
< span className = { styles . mobileResultPartyName } > { partido . nombreCandidato } < / span >
< span className = { styles . mobileResultCandidateName } > { partido . nombreCorto || partido . nombre } < / span >
< / >
) : (
< span className = { styles . mobileResultPartyName } > { partido . nombreCorto || partido . nombre } < / span >
) }
< / div >
< div className = { styles . mobileResultStats } >
< strong > { formatPercent ( partido . porcentaje ) } < / strong >
< span > { partido . votos . toLocaleString ( 'es-AR' ) } < / span >
< / div >
< / div >
< / div >
< div className = { styles . mobileResultInfo } >
{ partido . nombreCandidato ? (
< >
< span className = { styles . mobileResultPartyName } > { partido . nombreCandidato } < / span >
< span className = { styles . mobileResultCandidateName } > { partido . nombreCorto || partido . nombre } < / span >
< / >
) : (
< span className = { styles . mobileResultPartyName } > { partido . nombreCorto || partido . nombre } < / span >
) }
< / div >
< div className = { styles . mobileResultStats } >
< strong > { formatPercent ( partido . porcentaje ) } < / strong >
< span > { partido . votos . toLocaleString ( 'es-AR' ) } < / span >
< / div >
< / div >
) ;
) ;
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
@@ -63,75 +64,75 @@ interface MobileResultsCardProps {
setMobileView : ( view : 'mapa' | 'resultados' ) = > void ;
setMobileView : ( view : 'mapa' | 'resultados' ) = > void ;
}
}
const MobileResultsCard = ( {
const MobileResultsCard = ( {
eleccionId , ambitoId , categoriaId , ambitoNombre , ambitoNivel , mobileView , setMobileView
eleccionId , ambitoId , categoriaId , ambitoNombre , ambitoNivel , mobileView , setMobileView
} : MobileResultsCardProps ) = > {
} : MobileResultsCardProps ) = > {
const [ isExpanded , setIsExpanded ] = useState ( false ) ;
const { data } = useSuspenseQuery < PanelElectoralDto > ( {
const [ isExpanded , setIsExpanded ] = useState ( false ) ;
queryKey : [ 'panelElectoral' , eleccionId , ambitoId , categoriaId ] ,
queryFn : ( ) = > getPanelElectoral ( eleccionId , ambitoId , categoriaId ) ,
} ) ;
useEffect ( ( ) = > {
setIsExpanded ( ambitoNivel === 'municipio' ) ;
} , [ ambitoNivel ] ) ;
const topResults = data . resultadosPanel . slice ( 0 , 3 ) ;
const { data } = useSuspenseQuery < PanelElectoralDto > ( {
queryKey : [ 'panelElectoral' , eleccionId , ambitoId , categoriaId ] ,
queryFn : ( ) = > getPanelElectoral ( eleccionId , ambitoId , categoriaId ) ,
} ) ;
if ( topResults . length === 0 && ambitoNivel === 'pais' ) {
useEffect ( ( ) = > {
return null ;
setIsExpanded ( ambitoNivel === 'municipio' ) ;
}
}, [ ambitoNivel ] ) ;
// 3. Clases condicionales también se construyen con el objeto 'styles'
const topResults = data . resultadosPanel . slice ( 0 , 3 ) ;
const cardClasses = [
styles . mobileResultsCardContainer ,
isExpanded ? styles . expanded : '' ,
styles [ ` view- ${ mobileView } ` ]
] . join ( ' ' ) ;
return (
if ( topResults . length === 0 && ambitoNivel === 'pais' ) {
< div className = { cardClasses } >
return null ;
{ /* Sección Colapsable con Resultados */ }
}
< div className = { styles . collapsibleSection } >
< div className = { styles . mobileResultsHeader } onClick = { ( ) = > setIsExpanded ( ! isExpanded ) } >
< div className = { styles . headerInfo } >
< h4 > { ambitoNombre } < / h4 >
< span className = { styles . headerActionText } > { isExpanded ? 'Ocultar resultados' : 'Ver top 3' } < / span >
< / div >
< div className = { styles . headerToggleIcon } >
{ isExpanded ? < FiChevronDown / > : < FiChevronUp / > }
< / div >
< / div >
< div className = { styles . mobileResultsContent } >
{ topResults . length > 0 ? (
topResults . map ( partido = > < ResultRow key = { partido . id } partido = { partido } / > )
) : (
< p className = { styles . noResultsText } > No hay resultados para esta selección . < / p >
) }
< / div >
< / div >
{ /* Footer Fijo con Botones de Navegación */ }
// 3. Clases condicionales también se construyen con el objeto 'styles'
< div className = { styles . mobileCardViewToggle } >
const cardClasses = [
< button
styles . mobileResultsCardContainer ,
className = { ` ${ styles . toggleBtn } ${ mobileView === 'mapa' ? styles . active : '' } ` }
isExpanded ? styles . expanded : '' ,
onClick = { ( ) = > setMobileView ( 'mapa' ) }
styles [ ` view- ${ mobileView } ` ]
>
] . join ( ' ' ) ;
< FiMap / >
< span > Mapa < / span >
return (
< / button >
< div className = { cardClasses } >
< button
{ /* Sección Colapsable con Resultados */ }
className = { ` ${ styles . toggleBtn } ${ mobileView === 'resultados' ? styles . active : '' } ` }
< div className = { styles . collapsibleSection } >
onClick = { ( ) = > setMobileView ( 'resultados' ) }
< div className = { styles . mobileResultsHeader } onClick = { ( ) = > setIsExpanded ( ! isExpanded ) } >
>
< div className = { styles . headerInfo } >
< FiList / >
< h4 > { ambitoNombre } < / h4 >
< span > Detalles < / span >
< span className = { styles . headerActionText } > { isExpanded ? 'Ocultar resultados' : 'Ver top 3' } < / span >
< / button >
< / div >
< / div >
< div className = { styles . headerToggleIcon } >
{ isExpanded ? < FiChevronDown / > : < FiChevronUp / > }
< / div >
< / div >
< / div >
) ;
< div className = { styles . mobileResultsContent } >
{ topResults . length > 0 ? (
topResults . map ( partido = > < ResultRow key = { partido . id } partido = { partido } / > )
) : (
< p className = { styles . noResultsText } > No hay resultados para esta selección . < / p >
) }
< / div >
< / div >
{ /* Footer Fijo con Botones de Navegación */ }
< div className = { styles . mobileCardViewToggle } >
< button
className = { ` ${ styles . toggleBtn } ${ mobileView === 'mapa' ? styles . active : '' } ` }
onClick = { ( ) = > setMobileView ( 'mapa' ) }
>
< FiMap / >
< span > Mapa < / span >
< / button >
< button
className = { ` ${ styles . toggleBtn } ${ mobileView === 'resultados' ? styles . active : '' } ` }
onClick = { ( ) = > setMobileView ( 'resultados' ) }
>
< FiList / >
< span > Detalles < / span >
< / button >
< / div >
< / div >
) ;
} ;
} ;
// --- WIDGET PRINCIPAL ---
// --- WIDGET PRINCIPAL ---
@@ -157,10 +158,22 @@ const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId:
queryKey : [ 'panelElectoral' , eleccionId , ambitoActual . id , categoriaId ] ,
queryKey : [ 'panelElectoral' , eleccionId , ambitoActual . id , categoriaId ] ,
queryFn : ( ) = > getPanelElectoral ( eleccionId , ambitoActual . id , categoriaId ) ,
queryFn : ( ) = > getPanelElectoral ( eleccionId , ambitoActual . id , categoriaId ) ,
} ) ;
} ) ;
// Si la API devolvió la bandera 'sinDatos', mostramos un mensaje.
if ( data . sinDatos ) {
return (
< div style = { { padding : '2rem' , textAlign : 'center' , color : '#666' } } >
< h4 > Sin Resultados Detallados < / h4 >
< p > Aún no hay datos disponibles para esta selección . < / p >
< p > Por favor , intente de nuevo más tarde . < / p >
< / div >
) ;
}
// Si no, renderizamos los resultados.
return < PanelResultados resultados = { data . resultadosPanel } estadoRecuento = { data . estadoRecuento } / > ;
return < PanelResultados resultados = { data . resultadosPanel } estadoRecuento = { data . estadoRecuento } / > ;
} ;
} ;
export const PanelNacionalWidget = ( { eleccionId } : PanelNacionalWidgetProps ) = > {
export const PanelNacionalWidget = ( { eleccionId } : PanelNacionalWidgetProps ) = > {
const queryClient = useQueryClient ( ) ;
const [ ambitoActual , setAmbitoActual ] = useState < AmbitoState > ( { id : null , nivel : 'pais' , nombre : 'Argentina' , provinciaDistritoId : null } ) ;
const [ ambitoActual , setAmbitoActual ] = useState < AmbitoState > ( { id : null , nivel : 'pais' , nombre : 'Argentina' , provinciaDistritoId : null } ) ;
const [ categoriaId , setCategoriaId ] = useState < number > ( 2 ) ;
const [ categoriaId , setCategoriaId ] = useState < number > ( 2 ) ;
const [ isPanelOpen , setIsPanelOpen ] = useState ( true ) ;
const [ isPanelOpen , setIsPanelOpen ] = useState ( true ) ;
@@ -168,6 +181,16 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
const isMobile = useMediaQuery ( '(max-width: 800px)' ) ;
const isMobile = useMediaQuery ( '(max-width: 800px)' ) ;
const handleAmbitoSelect = ( nuevoAmbitoId : string , nuevoNivel : 'provincia' | 'municipio' , nuevoNombre : string ) = > {
const handleAmbitoSelect = ( nuevoAmbitoId : string , nuevoNivel : 'provincia' | 'municipio' , nuevoNombre : string ) = > {
if ( nuevoNivel === 'municipio' ) {
toast . promise (
queryClient . invalidateQueries ( { queryKey : [ 'panelElectoral' , eleccionId , nuevoAmbitoId , categoriaId ] } ) ,
{
loading : ` Cargando datos de ${ nuevoNombre } ... ` ,
success : < b > Datos cargados < / b > ,
error : < b > No se pudieron cargar los datos . < / b > ,
}
) ;
}
setAmbitoActual ( prev = > ( {
setAmbitoActual ( prev = > ( {
id : nuevoAmbitoId ,
id : nuevoAmbitoId ,
nivel : nuevoNivel ,
nivel : nuevoNivel ,
@@ -208,41 +231,43 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
return (
return (
< div className = { styles . panelNacionalContainer } >
< div className = { styles . panelNacionalContainer } >
< Toaster containerClassName = { styles . widgetToasterContainer } / >
< Toaster
position = "bottom-center"
containerClassName = { styles . widgetToasterContainer }
/ >
< header className = { styles . panelHeader } >
< header className = { styles . panelHeader } >
< div className = { styles . headerTopRow } >
< 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 ) }
// 4. Usamos un prefijo de clase simple que se asociará con las clases del módulo CSS
classNamePrefix = "categoriaSelector"
classNamePrefix = "categoriaSelector"
className = { styles . categoriaSelectorContainer }
className = { styles . categoriaSelectorContainer }
isSearchable = { false }
isSearchable = { false }
/ >
/ >
< / div >
< / div >
< div className = { styles . headerBottomRow } >
< div className = { styles . headerBottomRow } >
< Breadcrumbs
< Breadcrumbs
nivel = { ambitoActual . nivel }
nivel = { ambitoActual . nivel }
nombreAmbito = { ambitoActual . nombre }
nombreAmbito = { ambitoActual . nombre }
nombreProvincia = { ambitoActual . provinciaNombre }
nombreProvincia = { ambitoActual . provinciaNombre }
onReset = { handleResetToPais }
onReset = { handleResetToPais }
onVolverProvincia = { handleVolverAProvincia }
onVolverProvincia = { handleVolverAProvincia }
/ >
{ ambitoActual . nivel === 'provincia' && ambitoActual . provinciaDistritoId && (
< MunicipioSearch
distritoId = { ambitoActual . provinciaDistritoId }
onMunicipioSelect = { ( municipioId , municipioNombre ) = >
handleAmbitoSelect ( municipioId , 'municipio' , municipioNombre )
}
/ >
/ >
{ ambitoActual . nivel === 'provincia' && ambitoActual . provinciaDistritoId && (
) }
< MunicipioSearch
distritoId = { ambitoActual . provinciaDistritoId }
onMunicipioSelect = { ( municipioId , municipioNombre ) = >
handleAmbitoSelect ( municipioId , 'municipio' , municipioNombre )
}
/ >
) }
< / div >
< / div >
< / header >
< / header >
< main className = { mainContentClasses } >
< main className = { mainContentClasses } >
< div className = { styles . mapaColumn } >
< div className = { styles . mapaColumn } >
< button className = { styles . panelToggleBtn } 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 = { styles . 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 >
@@ -254,17 +279,17 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
< / div >
< / div >
< Suspense fallback = { null } >
< Suspense fallback = { null } >
{ isMobile && (
{ isMobile && (
< MobileResultsCard
< MobileResultsCard
eleccionId = { eleccionId }
eleccionId = { eleccionId }
ambitoId = { ambitoActual . id }
ambitoId = { ambitoActual . id }
categoriaId = { categoriaId }
categoriaId = { categoriaId }
ambitoNombre = { ambitoActual . nombre }
ambitoNombre = { ambitoActual . nombre }
ambitoNivel = { ambitoActual . nivel }
ambitoNivel = { ambitoActual . nivel }
mobileView = { mobileView }
mobileView = { mobileView }
setMobileView = { setMobileView }
setMobileView = { setMobileView }
/ >
/ >
) }
) }
< / Suspense >
< / Suspense >
< / main >
< / main >
< / div >
< / div >