283 lines
11 KiB
TypeScript
283 lines
11 KiB
TypeScript
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
|
||
|
||
import { useMemo, useState, Suspense, useEffect } from 'react';
|
||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||
import { getPanelElectoral } from '../../../apiService';
|
||
import { MapaNacional } from './components/MapaNacional';
|
||
import { PanelResultados } from './components/PanelResultados';
|
||
import { Breadcrumbs } from './components/Breadcrumbs';
|
||
import { MunicipioSearch } from './components/MunicipioSearch';
|
||
import styles from './PanelNacional.module.css';
|
||
import Select from 'react-select';
|
||
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
|
||
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||
import toast, { Toaster } from 'react-hot-toast';
|
||
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
|
||
import { assetBaseUrl } from '../../../apiService';
|
||
import { useQueryClient } from '@tanstack/react-query';
|
||
|
||
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
|
||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||
|
||
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
|
||
<div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}>
|
||
<div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
|
||
<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>
|
||
);
|
||
|
||
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
|
||
interface MobileResultsCardProps {
|
||
eleccionId: number;
|
||
ambitoId: string | null;
|
||
categoriaId: number;
|
||
ambitoNombre: string;
|
||
ambitoNivel: 'pais' | 'provincia' | 'municipio';
|
||
mobileView: 'mapa' | 'resultados';
|
||
setMobileView: (view: 'mapa' | 'resultados') => void;
|
||
}
|
||
|
||
const MobileResultsCard = ({
|
||
eleccionId, ambitoId, categoriaId, ambitoNombre, ambitoNivel, mobileView, setMobileView
|
||
}: MobileResultsCardProps) => {
|
||
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||
queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId, ambitoNivel],
|
||
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId, ambitoNivel),
|
||
refetchInterval: 180000,
|
||
});
|
||
|
||
useEffect(() => {
|
||
setIsExpanded(ambitoNivel === 'municipio');
|
||
}, [ambitoNivel]);
|
||
|
||
const topResults = data.resultadosPanel.slice(0, 3);
|
||
|
||
if (topResults.length === 0 && ambitoNivel === 'pais') {
|
||
return null;
|
||
}
|
||
|
||
const cardClasses = [
|
||
styles.mobileResultsCardContainer,
|
||
isExpanded ? styles.expanded : '',
|
||
styles[`view-${mobileView}`]
|
||
].join(' ');
|
||
|
||
return (
|
||
<div className={cardClasses}>
|
||
<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>
|
||
<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 ---
|
||
interface PanelNacionalWidgetProps {
|
||
eleccionId: number;
|
||
}
|
||
|
||
type AmbitoState = {
|
||
id: string | null;
|
||
nivel: 'pais' | 'provincia' | 'municipio';
|
||
nombre: string;
|
||
provinciaNombre?: string;
|
||
provinciaDistritoId?: string | null;
|
||
};
|
||
|
||
const CATEGORIAS_NACIONALES = [
|
||
{ value: 3, label: 'Diputados Nacionales' },
|
||
{ value: 2, label: 'Senadores Nacionales' },
|
||
];
|
||
|
||
const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => {
|
||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||
queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel],
|
||
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel),
|
||
refetchInterval: 180000,
|
||
});
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />;
|
||
};
|
||
|
||
export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => {
|
||
const queryClient = useQueryClient();
|
||
const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||
const [categoriaId, setCategoriaId] = useState<number>(3);
|
||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
|
||
const isMobile = useMediaQuery('(max-width: 800px)');
|
||
|
||
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
|
||
if (nuevoNivel === 'municipio') {
|
||
toast.promise(
|
||
queryClient.invalidateQueries({ queryKey: ['panelElectoral', eleccionId, nuevoAmbitoId, categoriaId, nuevoNivel] }),
|
||
{
|
||
loading: `Cargando datos de ${nuevoNombre}...`,
|
||
error: <b>No se pudieron cargar los datos.</b>,
|
||
}
|
||
);
|
||
}
|
||
setAmbitoActual(prev => ({
|
||
id: nuevoAmbitoId,
|
||
nivel: nuevoNivel,
|
||
nombre: nuevoNombre,
|
||
provinciaNombre: nuevoNivel === 'municipio' ? prev.provinciaNombre : (nuevoNivel === 'provincia' ? nuevoNombre : undefined),
|
||
provinciaDistritoId: nuevoNivel === 'provincia' ? nuevoAmbitoId : prev.provinciaDistritoId
|
||
}));
|
||
};
|
||
|
||
const handleResetToPais = () => {
|
||
setAmbitoActual({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||
};
|
||
|
||
const handleVolverAProvincia = () => {
|
||
if (ambitoActual.provinciaDistritoId && ambitoActual.provinciaNombre) {
|
||
setAmbitoActual({
|
||
id: ambitoActual.provinciaDistritoId,
|
||
nivel: 'provincia',
|
||
nombre: ambitoActual.provinciaNombre,
|
||
provinciaDistritoId: ambitoActual.provinciaDistritoId,
|
||
provinciaNombre: ambitoActual.provinciaNombre,
|
||
});
|
||
} else {
|
||
handleResetToPais();
|
||
}
|
||
};
|
||
|
||
const selectedCategoria = useMemo(() =>
|
||
CATEGORIAS_NACIONALES.find(c => c.value === categoriaId),
|
||
[categoriaId]
|
||
);
|
||
|
||
const mainContentClasses = [
|
||
styles.panelMainContent,
|
||
!isPanelOpen ? styles.panelCollapsed : '',
|
||
isMobile ? styles[`mobile-view-${mobileView}`] : ''
|
||
].join(' ');
|
||
|
||
return (
|
||
<div className={styles.panelNacionalContainer}>
|
||
<Toaster
|
||
position="bottom-center"
|
||
containerClassName={styles.widgetToasterContainer}
|
||
/>
|
||
<header className={styles.panelHeader}>
|
||
<div className={styles.headerTopRow}>
|
||
<Select
|
||
options={CATEGORIAS_NACIONALES}
|
||
value={selectedCategoria}
|
||
onChange={(option) => option && setCategoriaId(option.value)}
|
||
classNamePrefix="categoriaSelector"
|
||
className={styles.categoriaSelectorContainer}
|
||
isSearchable={false}
|
||
/>
|
||
</div>
|
||
<div className={styles.headerBottomRow}>
|
||
<Breadcrumbs
|
||
nivel={ambitoActual.nivel}
|
||
nombreAmbito={ambitoActual.nombre}
|
||
nombreProvincia={ambitoActual.provinciaNombre}
|
||
onReset={handleResetToPais}
|
||
onVolverProvincia={handleVolverAProvincia}
|
||
/>
|
||
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
|
||
<MunicipioSearch
|
||
distritoId={ambitoActual.provinciaDistritoId}
|
||
onMunicipioSelect={(municipioId, municipioNombre) =>
|
||
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<main className={mainContentClasses}>
|
||
<div className={styles.mapaColumn}>
|
||
<button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '›' : '‹'} </button>
|
||
|
||
<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} />
|
||
</Suspense>
|
||
</div>
|
||
<div className={styles.resultadosColumn}>
|
||
<Suspense fallback={<div className={styles.spinner} />}>
|
||
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
|
||
</Suspense>
|
||
</div>
|
||
|
||
<Suspense fallback={null}>
|
||
{isMobile && (
|
||
<MobileResultsCard
|
||
eleccionId={eleccionId}
|
||
ambitoId={ambitoActual.id}
|
||
categoriaId={categoriaId}
|
||
ambitoNombre={ambitoActual.nombre}
|
||
ambitoNivel={ambitoActual.nivel}
|
||
mobileView={mobileView}
|
||
setMobileView={setMobileView}
|
||
/>
|
||
)}
|
||
</Suspense>
|
||
</main>
|
||
</div>
|
||
);
|
||
}; |