Fix Mapa Error (Sección Sin Datos)

This commit is contained in:
2025-10-17 13:55:38 -03:00
parent 45421f5c5f
commit 92c80f195b
4 changed files with 161 additions and 121 deletions

View File

@@ -252,12 +252,26 @@ export const getPanelElectoral = async (eleccionId: number, ambitoId: string | n
let url = ambitoId let url = ambitoId
? `/elecciones/${eleccionId}/panel/${ambitoId}` ? `/elecciones/${eleccionId}/panel/${ambitoId}`
: `/elecciones/${eleccionId}/panel`; : `/elecciones/${eleccionId}/panel`;
// Añadimos categoriaId como un query parameter
url += `?categoriaId=${categoriaId}`; url += `?categoriaId=${categoriaId}`;
const { data } = await apiClient.get(url); try {
return data; const { data } = await apiClient.get(url);
return data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
console.warn(`API devolvió 404 para ${url}. Devolviendo un estado vacío.`);
// Devolvemos el objeto vacío PERO con la nueva bandera activada
return {
ambitoNombre: 'Sin Datos',
mapaData: [],
resultadosPanel: [],
estadoRecuento: { participacionPorcentaje: 0, mesasTotalizadasPorcentaje: 0 },
sinDatos: true,
};
}
throw error;
}
}; };
export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => { export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => {
@@ -295,12 +309,12 @@ export const getMunicipiosPorDistrito = async (distritoId: string): Promise<Cata
}; };
export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise<CategoriaResumenHome> => { export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise<CategoriaResumenHome> => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
eleccionId: eleccionId.toString(), eleccionId: eleccionId.toString(),
distritoId: distritoId, distritoId: distritoId,
categoriaId: categoriaId.toString(), categoriaId: categoriaId.toString(),
}); });
const url = `/elecciones/home-resumen?${queryParams.toString()}`; const url = `/elecciones/home-resumen?${queryParams.toString()}`;
const { data } = await apiClient.get(url); const { data } = await apiClient.get(url);
return data; return data;
}; };

View File

@@ -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 ---
@@ -64,74 +65,74 @@ interface MobileResultsCardProps {
} }
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 [isExpanded, setIsExpanded] = useState(false);
const { data } = useSuspenseQuery<PanelElectoralDto>({ const { data } = useSuspenseQuery<PanelElectoralDto>({
queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId], queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId],
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId), queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId),
}); });
useEffect(() => { useEffect(() => {
setIsExpanded(ambitoNivel === 'municipio'); setIsExpanded(ambitoNivel === 'municipio');
}, [ambitoNivel]); }, [ambitoNivel]);
const topResults = data.resultadosPanel.slice(0, 3); const topResults = data.resultadosPanel.slice(0, 3);
if (topResults.length === 0 && ambitoNivel === 'pais') { if (topResults.length === 0 && ambitoNivel === 'pais') {
return null; return null;
} }
// 3. Clases condicionales también se construyen con el objeto 'styles' // 3. Clases condicionales también se construyen con el objeto 'styles'
const cardClasses = [ const cardClasses = [
styles.mobileResultsCardContainer, styles.mobileResultsCardContainer,
isExpanded ? styles.expanded : '', isExpanded ? styles.expanded : '',
styles[`view-${mobileView}`] styles[`view-${mobileView}`]
].join(' '); ].join(' ');
return ( return (
<div className={cardClasses}> <div className={cardClasses}>
{/* Sección Colapsable con Resultados */} {/* Sección Colapsable con Resultados */}
<div className={styles.collapsibleSection}> <div className={styles.collapsibleSection}>
<div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}> <div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}>
<div className={styles.headerInfo}> <div className={styles.headerInfo}>
<h4>{ambitoNombre}</h4> <h4>{ambitoNombre}</h4>
<span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span> <span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
</div> </div>
<div className={styles.headerToggleIcon}> <div className={styles.headerToggleIcon}>
{isExpanded ? <FiChevronDown /> : <FiChevronUp />} {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> </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,35 +231,37 @@ 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}>
@@ -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>

View File

@@ -195,7 +195,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
if (newZoom > initialProvincePositionRef.current.zoom) { if (newZoom > initialProvincePositionRef.current.zoom) {
toast.success('Desplazamiento Habilitado', { toast.success('Desplazamiento Habilitado', {
icon: '🖐️', icon: '🖐️',
style: { background: '#32e5f1ff', color: 'white' }, style: { background: '#32e5f1ff', color: 'white', zIndex: 9999},
duration: 1000, duration: 1000,
}); });
} }

View File

@@ -131,6 +131,7 @@ export interface PanelElectoralDto {
mapaData: ResultadoMapaDto[]; mapaData: ResultadoMapaDto[];
resultadosPanel: ResultadoTicker[]; resultadosPanel: ResultadoTicker[];
estadoRecuento: EstadoRecuentoTicker; estadoRecuento: EstadoRecuentoTicker;
sinDatos?: boolean;
} }
// --- TIPOS PARA EL WIDGET DE TARJETAS NACIONALES --- // --- TIPOS PARA EL WIDGET DE TARJETAS NACIONALES ---