Feat Widgets Controles y Estilos

This commit is contained in:
2025-10-03 13:26:20 -03:00
parent 1719e79723
commit 64d45a7a39
17 changed files with 544 additions and 278 deletions

View File

@@ -289,6 +289,11 @@ export const getResumenPorProvincia = async (eleccionId: number, params: Resumen
return data;
};
export const getMunicipiosPorDistrito = async (distritoId: string): Promise<CatalogoItem[]> => {
const response = await apiClient.get(`/catalogos/municipios-por-distrito/${distritoId}`);
return response.data;
};
export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise<CategoriaResumenHome> => {
const queryParams = new URLSearchParams({
eleccionId: eleccionId.toString(),

View File

@@ -144,7 +144,6 @@
font-size: 1.2rem;
}
.panel-main-content {
display: flex;
height: 75vh;
@@ -180,15 +179,16 @@
.partido-logo {
flex-shrink: 0;
width: 65px; /* ANTES: 75px */
height: 65px; /* ANTES: 75px */
width: 65px;
height: 65px;
border-radius: 12px;
box-sizing: border-box;
}
.partido-logo img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10%;
border-radius: 12px;
}
.partido-main-content {
@@ -279,7 +279,6 @@
display: block;
}
/* --- MAPA Y ELEMENTOS ASOCIADOS --- */
.mapa-componente-container {
width: 100%;
@@ -313,7 +312,7 @@
transition: transform 0.75s ease-in-out;
}
/* AÑADIDO: Desactivar la transición durante el arrastre */
/* Desactivar la transición durante el arrastre */
.rsm-zoomable-group.panning {
transition: none;
}
@@ -475,52 +474,12 @@
margin-top: 4px;
}
/* --- NUEVOS ESTILOS PARA EL TOGGLE MÓVIL --- */
.mobile-view-toggle {
display: none;
position: absolute;
/* <-- CAMBIO: De 'fixed' a 'absolute' */
bottom: 10px;
/* <-- AJUSTE: Menos espacio desde abajo */
left: 50%;
transform: translateX(-50%);
z-index: 100;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 30px;
padding: 5px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 5px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.mobile-view-toggle .toggle-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
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;
}
.mobile-view-toggle .toggle-btn.active {
background-color: #007bff;
color: white;
}
/* --- ESTILOS PARA LOS BOTONES DE ZOOM DEL MAPA --- */
.zoom-controls-container {
position: absolute;
top: 5px;
right: 10px;
z-index: 30;
/* Debe ser MAYOR que el z-index del header (20) */
display: flex;
flex-direction: column;
gap: 5px;
@@ -542,15 +501,12 @@
}
.zoom-icon-wrapper {
/* Contenedor del icono */
display: flex;
/* Necesario para que el SVG interno se alinee */
align-items: center;
justify-content: center;
}
.zoom-icon-wrapper svg {
/* Apunta directamente al SVG del icono */
width: 20px;
height: 20px;
color: #333;
@@ -558,9 +514,7 @@
.zoom-btn.disabled {
opacity: 0.5;
/* Lo hace semitransparente */
cursor: not-allowed;
/* Muestra el cursor de "no permitido" */
}
.zoom-btn:hover {
@@ -576,22 +530,41 @@
cursor: grab;
}
/* El cursor 'grabbing' se aplica automáticamente por el navegador durante el arrastre */
.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 (ENFOQUE FINAL CON CAPAS) --- */
/* --- MEDIA QUERY PARA RESPONSIVE (REFACTORIZADA) --- */
@media (max-width: 800px) {
/* --- CONFIGURACIÓN GENERAL --- */
html,
body {
width: 100%;
overflow-x: hidden;
.panel-nacional-container {
display: flex;
flex-direction: column;
height: 100vh;
padding: 0;
border: none;
border-radius: 0;
}
/* Controles de vista y header (sin cambios) */
.mobile-view-toggle {
display: flex;
.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 {
@@ -608,21 +581,10 @@
width: 100%;
}
/* --- NUEVO LAYOUT DE CAPAS SUPERPUESTAS --- */
/* 1. El contenedor principal ahora es un ancla de posicionamiento */
.panel-main-content {
position: relative;
/* Clave para que los hijos se posicionen dentro de él */
height: calc(100vh - 200px);
/* Le damos una altura fija y predecible */
min-height: 450px;
}
/* 2. Ambas columnas son capas que ocupan el 100% del espacio del padre */
.mapa-column,
.resultados-column {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@@ -630,30 +592,20 @@
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
}
/* Le damos un estilo específico a la columna del mapa para subirla */
.mapa-column {
top: -50px;
left: -10px;
z-index: 10;
}
/* Hacemos que la columna de resultados pueda tener su propio scroll... */
.resultados-column {
top: 0;
/* Aseguramos que los resultados se queden en su sitio */
padding: 1rem;
overflow-y: auto;
z-index: 15;
}
/* 3. Lógica de visibilidad: controlamos qué capa está "arriba" */
.panel-main-content.mobile-view-mapa .resultados-column {
opacity: 0;
visibility: hidden;
/* Esta es la propiedad clave que ya tenías, pero es importante verificarla */
pointer-events: none;
/* Asegura que la capa oculta no bloquee el mapa */
}
.panel-main-content.mobile-view-resultados .mapa-column {
@@ -662,85 +614,35 @@
pointer-events: none;
}
/* Hacemos que la columna de resultados pueda tener su propio scroll si el contenido es largo */
.resultados-column {
padding: 1rem;
overflow-y: auto;
}
/* 4. Estilos de los resultados (ya estaban bien, se mantienen) */
.partido-fila {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
/* Grosor del borde */
border-radius: 12px;
/* Redondeamos las esquinas */
padding-left: 1rem;
/* Espacio a la izquierda */
}
.partido-logo {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.partido-main-content {
flex-grow: 1;
min-width: 0;
}
.partido-top-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.partido-info-wrapper {
min-width: 0;
}
.partido-nombre {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partido-stats {
text-align: right;
flex-shrink: 0;
padding-left: 0.5rem;
}
/* --- AJUSTE DE TAMAÑO DEL CONTENEDOR INTERNO DEL MAPA --- */
.mapa-column .mapa-componente-container,
.mapa-column .mapa-render-area {
height: 100%;
}
/* Margen de seguridad para el último elemento de la lista de resultados */
.panel-partidos-container .partido-fila:last-child {
margin-bottom: 90px;
}
.zoom-controls-container {
top: 55px;
.zoom-controls-container, .mapa-volver-btn {
top: 15px;
}
.mapa-volver-btn {
top: 55px;
left: 12px;
.header-bottom-row {
flex-direction: column;
align-items: stretch; /* Para que ambos elementos ocupen el ancho completo */
gap: 1rem;
}
.municipio-search-container {
min-width: 100%; /* El buscador ocupa todo el ancho en móvil */
}
/* --- MEDIA QUERY ADICIONAL PARA MÓVIL EN HORIZONTAL --- */
/* Se activa cuando la pantalla es ancha pero no muy alta, como un teléfono en landscape */
@media (max-width: 900px) and (orientation: landscape) {
/* Layout flexible de dos columnas */
.panel-main-content {
display: flex;
flex-direction: row;
@@ -748,11 +650,8 @@
height: 85vh;
min-height: 400px;
}
.mapa-column,
.resultados-column {
.mapa-column, .resultados-column {
position: static;
/* Desactivamos el posicionamiento absoluto */
height: auto;
width: auto;
opacity: 1;
@@ -760,23 +659,177 @@
pointer-events: auto;
flex: 3;
overflow-y: auto;
/* Permitimos que la columna de resultados tenga su propio scroll */
}
.resultados-column {
flex: 2;
min-width: 300px;
/* Un mínimo para que no se comprima */
}
.mobile-results-card-container { display: none; }
.panel-toggle-btn { display: flex; }
}
}
/* 3. Ocultamos los botones de cambio de vista móvil, ya que ambas se ven */
.mobile-view-toggle {
display: none;
}
/* --- ESTILOS PARA LA TARJETA DE RESULTADOS EN MÓVIL (ACTUALIZADOS) --- */
.mobile-results-card-container {
position: absolute;
bottom: 10px;
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;
}
/* 4. Mostramos de nuevo el botón lateral para colapsar el panel de resultados */
.panel-toggle-btn {
display: flex;
.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; /* REDUCIDO */
cursor: pointer;
}
.mobile-results-header .header-info {
display: flex; /* AÑADIDO */
align-items: baseline; /* AÑADIDO */
gap: 12px; /* AÑADIDO */
}
.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; /* Se achica el título */
}
.mobile-results-header .header-info .header-action-text {
font-size: 0.7rem; /* Se achica el texto de acción */
}
.mobile-card-view-toggle .toggle-btn {
padding: 6px 10px; /* Se reduce el padding de los botones */
font-size: 0.8rem; /* Se achica la fuente de los botones */
}
}

View File

@@ -1,17 +1,130 @@
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
import { useMemo, useState, Suspense } from 'react';
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 './PanelNacional.css';
import Select from 'react-select';
import type { PanelElectoralDto } from '../../../types/types';
import { FiMap, FiList } from 'react-icons/fi';
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
import { useMediaQuery } from './hooks/useMediaQuery';
import { Toaster } from 'react-hot-toast';
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../apiService';
// --- COMPONENTE INTERNO PARA LA TARJETA DE RESULTADOS EN MÓVIL ---
interface MobileResultsCardProps {
eleccionId: number;
ambitoId: string | null;
categoriaId: number;
ambitoNombre: string;
ambitoNivel: 'pais' | 'provincia' | 'municipio';
}
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
<div className="mobile-result-row" style={{ borderLeftColor: partido.color || '#ccc' }}>
<div className="mobile-result-logo" style={{ backgroundColor: partido.color || '#e9ecef' }}>
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div>
<div className="mobile-result-info">
{partido.nombreCandidato ? (
<>
<span className="mobile-result-party-name">{partido.nombreCandidato}</span>
<span className="mobile-result-candidate-name">{partido.nombreCorto || partido.nombre}</span>
</>
) : (
<span className="mobile-result-party-name">{partido.nombreCorto || partido.nombre}</span>
)}
</div>
<div className="mobile-result-stats">
<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],
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId),
});
useEffect(() => {
setIsExpanded(ambitoNivel === 'municipio');
}, [ambitoNivel]);
const topResults = data.resultadosPanel.slice(0, 4);
if (topResults.length === 0 && ambitoNivel === 'pais') {
return null;
}
return (
<div className={`mobile-results-card-container ${isExpanded ? 'expanded' : ''} view-${mobileView}`}>
{/* Sección Colapsable con Resultados */}
<div className="collapsible-section">
<div className="mobile-results-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="header-info">
<h4>{ambitoNombre}</h4>
{/* Se añade una clase para estilizar este texto específicamente */}
<span className="header-action-text">{isExpanded ? 'Ocultar resultados' : 'Ver top 4'}</span>
</div>
<div className="header-toggle-icon">
{isExpanded ? <FiChevronUp /> : <FiChevronDown />}
</div>
</div>
<div className="mobile-results-content">
{topResults.length > 0 ? (
topResults.map(partido => <ResultRow key={partido.id} partido={partido} />)
) : (
<p className="no-results-text">No hay resultados para esta selección.</p>
)}
</div>
</div>
{/* Footer Fijo con Botones de Navegación */}
<div className="mobile-card-view-toggle">
<button
className={`toggle-btn ${mobileView === 'mapa' ? 'active' : ''}`}
onClick={() => setMobileView('mapa')}
>
<FiMap />
<span>Mapa</span>
</button>
<button
className={`toggle-btn ${mobileView === 'resultados' ? 'active' : ''}`}
onClick={() => setMobileView('resultados')}
>
<FiList />
<span>Resultados</span>
</button>
</div>
</div>
);
};
// --- WIDGET PRINCIPAL ---
interface PanelNacionalWidgetProps {
eleccionId: number;
}
@@ -42,7 +155,6 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
const [categoriaId, setCategoriaId] = useState<number>(2);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
// --- DETECCIÓN DE VISTA MÓVIL ---
const isMobile = useMediaQuery('(max-width: 800px)');
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
@@ -91,62 +203,55 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
classNamePrefix="categoria-selector"
isSearchable={false}
/>
<Breadcrumbs
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvincia={ambitoActual.provinciaNombre}
onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia}
/>
</div>
{/* --- 2. NUEVO CONTENEDOR PARA BREADCRUMBS Y BUSCADOR --- */}
<div className="header-bottom-row">
<Breadcrumbs
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvincia={ambitoActual.provinciaNombre}
onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia}
/>
{/* --- 3. RENDERIZADO CONDICIONAL DEL BUSCADOR --- */}
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
<MunicipioSearch
distritoId={ambitoActual.provinciaDistritoId}
onMunicipioSelect={(municipioId, municipioNombre) =>
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
}
/>
)}
</div>
</header>
<main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''} ${isMobile ? `mobile-view-${mobileView}` : ''}`}>
<div className="mapa-column">
<button className="panel-toggle-btn" onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}>
{isPanelOpen ? '' : ''}
</button>
<button className="panel-toggle-btn" onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '' : ''} </button>
<Suspense fallback={<div className="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>
</div>
<div className="resultados-column">
<Suspense fallback={<div className="spinner" />}>
<PanelContenido
eleccionId={eleccionId}
ambitoActual={ambitoActual}
categoriaId={categoriaId}
/>
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
</Suspense>
</div>
</main>
{/* --- NUEVO CONTROLADOR DE VISTA PARA MÓVIL --- */}
<div className="mobile-view-toggle">
<button
className={`toggle-btn ${mobileView === 'mapa' ? 'active' : ''}`}
onClick={() => setMobileView('mapa')}
>
<FiMap />
<span>Mapa</span>
</button>
<button
className={`toggle-btn ${mobileView === 'resultados' ? 'active' : ''}`}
onClick={() => setMobileView('resultados')}
>
<FiList />
<span>Resultados</span>
</button>
</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>
);
};

View File

@@ -106,6 +106,10 @@
padding: 0.5rem 1rem;
}
.candidato-row:last-child {
border-bottom: none;
}
.candidato-row {
display: flex;
align-items: center;
@@ -114,19 +118,24 @@
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid; /* Grosor del borde */
border-radius: 12px; /* Redondeamos las esquinas para un look más suave */
padding-left: 1rem; /* Añadimos un poco de espacio a la izquierda */
padding-left: 0.75rem; /* Añadimos un poco de espacio a la izquierda */
}
.candidato-row:last-child {
border-bottom: none;
}
.candidato-foto {
/* Nuevo contenedor para el logo con fondo de color */
.candidato-foto-wrapper {
width: 60px;
height: 60px;
border-radius: 5%;
object-fit: cover;
border-radius: 12px;
flex-shrink: 0;
box-sizing: border-box;
background-color: #e9ecef; /* Color de fallback */
}
/* La imagen ahora llena su nuevo contenedor */
.candidato-foto {
width: 100%;
height: 100%;
border-radius: 12px;
}
.candidato-data {

View File

@@ -1,4 +1,3 @@
// src/features/legislativas/nacionales/components/MapaNacional.tsx
import axios from 'axios';
import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
@@ -12,6 +11,7 @@ import { MapaProvincial } from './MapaProvincial';
import { CabaLupa } from './CabaLupa';
import { BiZoomIn, BiZoomOut } from "react-icons/bi";
import toast from 'react-hot-toast';
import { useMediaQuery } from '../hooks/useMediaQuery';
const DEFAULT_MAP_COLOR = '#E0E0E0';
const FADED_BACKGROUND_COLOR = '#F0F0F0';
@@ -19,15 +19,21 @@ const normalizarTexto = (texto: string = '') => texto.trim().toUpperCase().norma
type PointTuple = [number, number];
const PROVINCE_VIEW_CONFIG: Record<string, { center: PointTuple; zoom: number }> = {
"BUENOS AIRES": { center: [-60.5, -37.3], zoom: 5 },
"SANTA CRUZ": { center: [-69.5, -49.3], zoom: 5 },
"CIUDAD AUTONOMA DE BUENOS AIRES": { center: [-58.45, -34.6], zoom: 85 },
"CHUBUT": { center: [-68.5, -44.5], zoom: 5.5 },
"SANTA FE": { center: [-61, -31.2], zoom: 6 },
"CORRIENTES": { center: [-58, -29], zoom: 7 },
"RIO NEGRO": { center: [-67.5, -40], zoom: 5.5 },
"TIERRA DEL FUEGO": { center: [-66.5, -54.2], zoom: 7 },
interface ViewConfig {
center: PointTuple;
zoom: number;
}
const PROVINCE_VIEW_CONFIG: Record<string, { desktop: ViewConfig; mobile?: ViewConfig }> = {
"BUENOS AIRES": { desktop: { center: [-60.5, -37.3], zoom: 5 }, mobile: { center: [-60, -38], zoom: 5.5 } },
"SANTA CRUZ": { desktop: { center: [-69.5, -49.3], zoom: 5 }, mobile: { center: [-69.5, -50], zoom: 4 } },
"CIUDAD AUTONOMA DE BUENOS AIRES": { desktop: { center: [-58.44, -34.65], zoom: 150 } },
"CHUBUT": { desktop: { center: [-68.5, -44.5], zoom: 5.5 }, mobile: { center: [-68, -44.5], zoom: 4.5 } },
"SANTA FE": { desktop: { center: [-61, -31.2], zoom: 6 }, mobile: { center: [-61, -31.5], zoom: 7.5 } },
"CORRIENTES": { desktop: { center: [-58, -29], zoom: 7 }, mobile: { center: [-57.5, -28.8], zoom: 9 } },
"RIO NEGRO": { desktop: { center: [-67.5, -40], zoom: 5.5 }, mobile: { center: [-67.5, -40], zoom: 4.3 } },
"SALTA": { desktop: { center: [-64.5, -24], zoom: 7 }, mobile: { center: [-65.5, -24.5], zoom: 6 } },
"TIERRA DEL FUEGO": { desktop: { center: [-66.5, -54.2], zoom: 7 }, mobile: { center: [-66, -54], zoom: 7.5 } },
};
const LUPA_SIZE_RATIO = 0.2;
@@ -48,15 +54,20 @@ interface MapaNacionalProps {
// --- CONFIGURACIONES DEL MAPA ---
const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] };
const mobileProjectionConfig = { scale: 1100, center: [-64, -41] as [number, number] };
const mobileProjectionConfig = { scale: 1100, center: [-64, -42.5] as [number, number] };
// --- LÍNEA A CALIBRAR ---
const mobileSmallProjectionConfig = { scale: 900, center: [-64, -43] as [number, number] };
export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nombreProvinciaActiva, provinciaDistritoId, onAmbitoSelect, onVolver, isMobileView }: MapaNacionalProps) => {
const isMobileSmall = useMediaQuery('(max-width: 380px)');
const [position, setPosition] = useState({
zoom: isMobileView ? 1.5 : 1.05, // 1.5 para móvil, 1.05 para desktop
center: [-65, -40] as PointTuple
zoom: isMobileView ? 1.5 : 1.05,
center: isMobileView ? mobileProjectionConfig.center : desktopProjectionConfig.center as PointTuple
});
const [isPanning, setIsPanning] = useState(false);
const initialProvincePositionRef = useRef<{ zoom: number, center: PointTuple } | null>(null);
const initialProvincePositionRef = useRef<ViewConfig | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const lupaRef = useRef<HTMLDivElement | null>(null);
@@ -82,32 +93,38 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
useEffect(() => {
if (nivel === 'pais') {
const currentMobileConfig = isMobileSmall ? mobileSmallProjectionConfig : mobileProjectionConfig;
const currentMobileZoom = isMobileSmall ? 1.4 : 1.5;
setPosition({
zoom: isMobileView ? 1.4 : 1.05,
center: [-65, -40]
zoom: isMobileView ? currentMobileZoom : 1.05,
center: isMobileView ? currentMobileConfig.center : desktopProjectionConfig.center
});
initialProvincePositionRef.current = null;
} else if (nivel === 'provincia') {
const nombreNormalizado = normalizarTexto(nombreAmbito);
const manualConfig = PROVINCE_VIEW_CONFIG[nombreNormalizado];
let provinceConfig = { zoom: 7, center: [-65, -40] as PointTuple };
let provinceConfig: ViewConfig | undefined;
if (manualConfig) {
provinceConfig = manualConfig;
provinceConfig = (isMobileView && manualConfig.mobile) ? manualConfig.mobile : manualConfig.desktop;
} else {
const provinciaGeo = geoDataNacional.objects.provincias.geometries.find((g: any) => normalizarTexto(g.properties.nombre) === nombreNormalizado);
if (provinciaGeo) {
const provinciaFeature = feature(geoDataNacional, provinciaGeo);
const centroid = geoCentroid(provinciaFeature);
provinceConfig = { zoom: 7, center: centroid as PointTuple };
provinceConfig = { zoom: isMobileView ? 8 : 7, center: centroid as PointTuple };
}
}
setPosition(provinceConfig);
initialProvincePositionRef.current = provinceConfig;
if (provinceConfig) {
setPosition(provinceConfig);
initialProvincePositionRef.current = provinceConfig;
}
}
}, [nivel, nombreAmbito, geoDataNacional, isMobileView]);
}, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]);
const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d]));
const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null;
@@ -173,14 +190,9 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
position.zoom > initialProvincePositionRef.current.zoom &&
!nombreMunicipioSeleccionado;
// --- INICIO DE LA CORRECCIÓN ---
const handleZoomIn = () => {
// Solo mostramos la notificación si el paneo NO está ya habilitado
if (!panEnabled && initialProvincePositionRef.current) {
// Calculamos cuál será el nuevo nivel de zoom
const newZoom = position.zoom * 1.8;
// Si el nuevo zoom supera el umbral inicial, activamos la notificación
if (newZoom > initialProvincePositionRef.current.zoom) {
toast.success('Desplazamiento Habilitado', {
icon: '🖐️',
@@ -193,10 +205,8 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
};
const handleZoomOut = () => {
// Solo mostramos la notificación si el paneo SÍ está habilitado actualmente
if (panEnabled && initialProvincePositionRef.current) {
const newZoom = position.zoom / 1.8;
// Si el nuevo zoom es igual o menor al umbral, desactivamos
if (newZoom <= initialProvincePositionRef.current.zoom) {
toast.error('Desplazamiento Deshabilitado', {
icon: '🔒',
@@ -205,7 +215,6 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
});
}
}
// La lógica para actualizar la posición no cambia
setPosition(prev => {
const newZoom = Math.max(prev.zoom / 1.8, 1);
const initialPos = initialProvincePositionRef.current;
@@ -254,7 +263,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
<div className="mapa-render-area">
<ComposableMap
projection="geoMercator"
projectionConfig={isMobileView ? mobileProjectionConfig : desktopProjectionConfig}
projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)}
style={{ width: "100%", height: "100%" }}
>
<ZoomableGroup

View File

@@ -0,0 +1,69 @@
// src/features/legislativas/nacionales/components/MunicipioSearch.tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Select, { type SingleValue } from 'react-select';
import { getMunicipiosPorDistrito } from '../../../../apiService';
import type { CatalogoItem } from '../../../../types/types';
interface MunicipioSearchProps {
distritoId: string;
onMunicipioSelect: (municipioId: string, municipioNombre: string) => void;
}
interface OptionType {
value: string;
label: string;
}
const customSelectStyles = {
control: (base: any) => ({
...base,
borderRadius: '8px',
borderColor: '#e0e0e0',
boxShadow: 'none',
'&:hover': { borderColor: '#007bff' }
}),
menu: (base: any) => ({
...base,
borderRadius: '8px',
zIndex: 30
})
};
export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSearchProps) => {
const [selectedOption, setSelectedOption] = useState<SingleValue<OptionType>>(null);
const { data: municipios = [], isLoading } = useQuery<CatalogoItem[]>({
queryKey: ['municipiosPorDistrito', distritoId],
queryFn: () => getMunicipiosPorDistrito(distritoId),
enabled: !!distritoId,
});
const options: OptionType[] = municipios.map(m => ({
value: m.id,
label: m.nombre
}));
const handleChange = (selected: SingleValue<OptionType>) => {
if (selected) {
onMunicipioSelect(selected.value, selected.label);
setSelectedOption(null); // Resetea el selector para que muestre el placeholder de nuevo
}
};
return (
<div className="municipio-search-container">
<Select
options={options}
onChange={handleChange}
value={selectedOption}
isLoading={isLoading}
placeholder="Buscar municipio..."
isClearable
isSearchable
styles={customSelectStyles}
noOptionsMessage={() => 'No se encontraron municipios'}
/>
</div>
);
};

View File

@@ -12,7 +12,6 @@ const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR');
const SvgDefs = () => (
<svg style={{ height: 0, width: 0, position: 'absolute' }}>
<defs>
{/* El gradiente ahora se define para que el color oscuro se mantenga en la segunda mitad del recorrido vertical */}
<linearGradient id="participationGradient" gradientTransform="rotate(90)">
<stop offset="0%" stopColor="#e0f3ffff" />
<stop offset="100%" stopColor="#007bff" />
@@ -40,13 +39,13 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
value={estadoRecuento.participacionPorcentaje}
text={formatPercent(estadoRecuento.participacionPorcentaje)}
strokeWidth={12}
circleRatio={0.75} /* Se convierte en un arco de 270 grados */
circleRatio={0.75}
styles={buildStyles({
textColor: '#333',
pathColor: 'url(#participationGradient)',
trailColor: '#e9ecef',
textSize: '22px',
rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */
rotation: 0.625,
})}
/>
<span>Participación</span>
@@ -56,13 +55,13 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
value={estadoRecuento.mesasTotalizadasPorcentaje}
text={formatPercent(estadoRecuento.mesasTotalizadasPorcentaje)}
strokeWidth={12}
circleRatio={0.75} /* Se convierte en un arco de 270 grados */
circleRatio={0.75}
styles={buildStyles({
textColor: '#333',
pathColor: 'url(#scrutinizedGradient)',
trailColor: '#e9ecef',
textSize: '22px',
rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */
rotation: 0.625,
})}
/>
<span>Escrutado</span>
@@ -76,7 +75,7 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
className="partido-fila"
style={{ borderLeftColor: partido.color || '#ccc' }}
>
<div className="partido-logo">
<div className="partido-logo" style={{ backgroundColor: partido.color || '#e9ecef' }}>
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div>
<div className="partido-main-content">

View File

@@ -4,7 +4,6 @@ import { MiniMapaSvg } from './MiniMapaSvg';
import { ImageWithFallback } from '../../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../../apiService';
// --- 1. AÑADIR LA PROP A AMBAS INTERFACES ---
interface CategoriaDisplayProps {
categoria: CategoriaResumen;
mostrarBancas?: boolean;
@@ -18,7 +17,6 @@ interface ProvinciaCardProps {
const formatNumber = (num: number) => num.toLocaleString('es-AR');
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
// --- 2. RECIBIR Y USAR LA PROP EN EL SUB-COMPONENTE ---
const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => {
return (
<div className="categoria-bloque">
@@ -30,20 +28,26 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
className="candidato-row"
style={{ borderLeftColor: res.color || '#ccc' }}
>
<ImageWithFallback
src={res.fotoUrl ?? undefined}
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
alt={res.nombreCandidato ?? res.nombreAgrupacion}
className="candidato-foto"
/>
{/* --- INICIO DE LA MODIFICACIÓN --- */}
<div className="candidato-foto-wrapper" style={{ backgroundColor: res.color || '#e9ecef' }}>
<ImageWithFallback
src={res.fotoUrl ?? undefined}
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
alt={res.nombreCandidato ?? res.nombreAgrupacion}
className="candidato-foto"
/>
</div>
{/* --- FIN DE LA MODIFICACIÓN --- */}
<div className="candidato-data">
{res.nombreCandidato && (
<span className="candidato-nombre">{res.nombreCandidato}</span>
{res.nombreCandidato ? (
<>
<span className="candidato-nombre">{res.nombreCandidato}</span>
<span className="candidato-partido">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
</>
) : (
<span className="candidato-nombre">{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
)}
<span className={`candidato-partido ${!res.nombreCandidato ? 'main-title' : ''}`}>
{res.nombreAgrupacion}
</span>
<div className="progress-bar-container">
<div className="progress-bar" style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} />
</div>
@@ -53,8 +57,6 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
<span className="stats-votos">{formatNumber(res.votos)} votos</span>
</div>
{/* --- 3. RENDERIZADO CONDICIONAL DEL CUADRO DE BANCAS --- */}
{/* Este div solo se renderizará si mostrarBancas es true */}
{mostrarBancas && (
<div className="stats-bancas">
+{res.bancasObtenidas}
@@ -82,7 +84,6 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
);
};
// --- 4. RECIBIR Y PASAR LA PROP EN EL COMPONENTE PRINCIPAL ---
export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => {
const colorGanador = data.categorias[0]?.resultados[0]?.color || '#d1d1d1';
@@ -101,7 +102,7 @@ export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => {
<CategoriaDisplay
key={categoria.categoriaId}
categoria={categoria}
mostrarBancas={mostrarBancas} // Pasar la prop hacia abajo
mostrarBancas={mostrarBancas}
/>
))}
</div>