Feat Widgets Carousel Selector de Porv. Fix Tablas Movil
This commit is contained in:
@@ -353,4 +353,9 @@ export const getTablaSecciones = async (eleccionId: number): Promise<ResultadoSe
|
||||
export const getResumenNacionalPorProvincia = async (eleccionId: number, categoriaId: number): Promise<ProvinciaResumen[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/resumen-nacional-por-provincia?categoriaId=${categoriaId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getProvincias = async (): Promise<CatalogoItem[]> => {
|
||||
const response = await apiClient.get('/catalogos/provincias');
|
||||
return response.data;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { HomeCarouselNacionalWidget } from './nacionales/HomeCarouselNacionalWid
|
||||
import { TablaConurbanoWidget } from './nacionales/TablaConurbanoWidget';
|
||||
import { TablaSeccionesWidget } from './nacionales/TablaSeccionesWidget';
|
||||
import { ResumenNacionalWidget } from './nacionales/ResumenNacionalWidget';
|
||||
import { HomeCarouselProvincialWidget } from './nacionales/HomeCarouselProvincialWidget';
|
||||
|
||||
// --- NUEVO COMPONENTE REUTILIZABLE PARA CONTENIDO COLAPSABLE ---
|
||||
const CollapsibleWidgetWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -90,7 +91,34 @@ export const DevAppLegislativas = () => {
|
||||
<HomeCarouselNacionalWidget
|
||||
eleccionId={2}
|
||||
categoriaId={2} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Senadores - Total País" mapLinkUrl={''} />
|
||||
titulo="Senadores - Total País" mapLinkUrl={''} />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel Provincial con Selector (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Categoría Diputados
|
||||
</p>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={3} titulo="Diputados" /></code>
|
||||
</p>
|
||||
<HomeCarouselProvincialWidget
|
||||
eleccionId={2}
|
||||
categoriaId={3} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Diputados"
|
||||
/>
|
||||
|
||||
<p style={descriptionStyle}>
|
||||
Categoría Senadores
|
||||
</p>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={2} titulo="Senadores" /></code>
|
||||
</p>
|
||||
<HomeCarouselProvincialWidget
|
||||
eleccionId={2}
|
||||
categoriaId={2} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Senadores"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- SECCIÓN PARA EL WIDGET DE TARJETAS CON EJEMPLOS --- */}
|
||||
|
||||
@@ -57,7 +57,7 @@ export const HomeCarouselNacionalWidget = ({ eleccionId, categoriaId, titulo, ma
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={styles.widgetHeader}>
|
||||
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
|
||||
<h2 className={styles.widgetTitle}>{titulo}</h2>
|
||||
<a href={mapLinkUrl} className={styles.mapLinkButton}>
|
||||
<TfiMapAlt />
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// src/features/legislativas/nacionales/HomeCarouselProvincialWidget.tsx
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select, { type SingleValue } from 'react-select';
|
||||
import { getHomeResumen, getProvincias } from '../../../apiService';
|
||||
import type { CatalogoItem } from '../../../types/types';
|
||||
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
|
||||
import { assetBaseUrl } from '../../../apiService';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Navigation, A11y } from 'swiper/modules';
|
||||
|
||||
// @ts-ignore
|
||||
import 'swiper/css';
|
||||
// @ts-ignore
|
||||
import 'swiper/css/navigation';
|
||||
import styles from './HomeCarouselWidget.module.css';
|
||||
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
categoriaId: number;
|
||||
titulo: string;
|
||||
}
|
||||
|
||||
interface OptionType {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// --- LÓGICA DE FILTRADO ---
|
||||
// 1. Definimos los IDs de distrito de las provincias que renuevan senadores.
|
||||
const PROVINCIAS_QUE_RENUEVAN_SENADORES = new Set(['01', '06', '08', '15', '16', '17', '22', '24']);
|
||||
const CATEGORIA_SENADORES = 2;
|
||||
// --- FIN LÓGICA DE FILTRADO ---
|
||||
|
||||
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const formatNumber = (num: number) => num.toLocaleString('es-AR');
|
||||
const formatDateTime = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return '...';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const HomeCarouselProvincialWidget = ({ eleccionId, categoriaId, titulo }: Props) => {
|
||||
// 2. Estado inicial nulo para que el useEffect lo establezca dinámicamente.
|
||||
const [selectedProvince, setSelectedProvince] = useState<OptionType | null>(null);
|
||||
|
||||
const { data: provincias = [], isLoading: isLoadingProvincias } = useQuery<CatalogoItem[]>({
|
||||
queryKey: ['provincias'],
|
||||
queryFn: getProvincias,
|
||||
});
|
||||
|
||||
// 3. Usamos useMemo para filtrar las opciones solo cuando sea necesario.
|
||||
const provinceOptions: OptionType[] = useMemo(() => {
|
||||
const allOptions = provincias.map(p => ({ value: p.id, label: p.nombre }));
|
||||
if (categoriaId === CATEGORIA_SENADORES) {
|
||||
return allOptions.filter(opt => PROVINCIAS_QUE_RENUEVAN_SENADORES.has(opt.value));
|
||||
}
|
||||
return allOptions;
|
||||
}, [provincias, categoriaId]);
|
||||
|
||||
// 4. useEffect para establecer y validar la provincia por defecto.
|
||||
useEffect(() => {
|
||||
if (provinceOptions.length > 0) {
|
||||
// Si no hay nada seleccionado, establece el default.
|
||||
if (!selectedProvince) {
|
||||
const defaultOption = provinceOptions.find(opt => opt.value === '01'); // CABA
|
||||
setSelectedProvince(defaultOption || provinceOptions[0]); // Si CABA no está, usa la primera opción.
|
||||
} else {
|
||||
// Si ya hay algo seleccionado, verifica que siga siendo válido. Si no, lo resetea.
|
||||
const isSelectedStillValid = provinceOptions.some(opt => opt.value === selectedProvince.value);
|
||||
if (!isSelectedStillValid) {
|
||||
setSelectedProvince(provinceOptions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [provinceOptions, selectedProvince]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['homeResumen', eleccionId, selectedProvince?.value, categoriaId],
|
||||
queryFn: () => getHomeResumen(eleccionId, selectedProvince!.value, categoriaId),
|
||||
enabled: !!selectedProvince, // La consulta solo se ejecuta si hay una provincia seleccionada
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const prevButtonClass = `prev-${uniqueId}`;
|
||||
const nextButtonClass = `next-${uniqueId}`;
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={styles.widgetHeader}>
|
||||
<h2 className={styles.widgetTitle}>{`${titulo} - ${selectedProvince?.label || '...'}`}</h2>
|
||||
<div className={styles.provinceSelector}>
|
||||
<Select
|
||||
value={selectedProvince}
|
||||
options={provinceOptions}
|
||||
onChange={(option: SingleValue<OptionType>) => option && setSelectedProvince(option)}
|
||||
isLoading={isLoadingProvincias}
|
||||
isSearchable={true}
|
||||
placeholder="Seleccionar provincia..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isLoading || !selectedProvince) && <div>Cargando resultados...</div>}
|
||||
{error && <div>No se pudieron cargar los datos.</div>}
|
||||
{data && selectedProvince && (
|
||||
<>
|
||||
<div className={styles.carouselContainer}>
|
||||
<Swiper
|
||||
modules={[Navigation, A11y]}
|
||||
spaceBetween={16}
|
||||
slidesPerView={1.3}
|
||||
navigation={{
|
||||
prevEl: `.${prevButtonClass}`,
|
||||
nextEl: `.${nextButtonClass}`,
|
||||
}}
|
||||
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 => (
|
||||
<SwiperSlide key={candidato.agrupacionId}>
|
||||
<div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
|
||||
<div className={styles.candidatePhotoWrapper}>
|
||||
<ImageWithFallback
|
||||
src={candidato.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={candidato.nombreCandidato ?? ''}
|
||||
className={styles.candidatePhoto}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.candidateDetails}>
|
||||
<div className={styles.candidateInfo}>
|
||||
{candidato.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.candidateName}>{candidato.nombreCandidato}</span>
|
||||
<span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.candidateResults}>
|
||||
<span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span>
|
||||
<span className={styles.votes}>{formatNumber(candidato.votos)} votos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div>
|
||||
<div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div>
|
||||
</div>
|
||||
|
||||
<div className={styles.topStatsBar}>
|
||||
<div>
|
||||
<span>Participación</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Mesas escrutadas</span>
|
||||
<span className={styles.shortText}>Escrutado</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos en blanco</span>
|
||||
<span className={styles.shortText}>En blanco</span>
|
||||
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos totales</span>
|
||||
<span className={styles.shortText}>Votos</span>
|
||||
<strong>{formatNumber(data.votosTotales)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.widgetFooter}>
|
||||
Última actualización: {formatDateTime(data.ultimaActualizacion)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,7 +23,7 @@
|
||||
padding: 0.75rem;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carouselContainer {
|
||||
@@ -49,6 +49,15 @@
|
||||
border: none;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.provinceSelector {
|
||||
min-width: 180px;
|
||||
flex-shrink: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.mapLinkButton {
|
||||
@@ -57,14 +66,15 @@
|
||||
gap: 0.4rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 0.4rem 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapLinkButton svg {
|
||||
@@ -94,18 +104,30 @@
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.topStatsBar > div {
|
||||
.topStatsBar>div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
border-right: 1px solid var(--border-color);
|
||||
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); }
|
||||
|
||||
.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;
|
||||
@@ -149,33 +171,40 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items:flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.candidateName, .partyName {
|
||||
.candidateName,
|
||||
.partyName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
color: var(--primary-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.candidateName {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.partyName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.candidateResults { text-align: right; flex-shrink: 0; }
|
||||
.candidateResults {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
@@ -183,6 +212,7 @@
|
||||
color: var(--primary-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.votes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
@@ -208,20 +238,22 @@
|
||||
|
||||
/* Usamos el pseudo-elemento ::after para mostrar el icono SVG como fondo */
|
||||
.navButton::after {
|
||||
content: ''; /* Es necesario para que el pseudo-elemento se muestre */
|
||||
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%;
|
||||
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+");
|
||||
@@ -230,6 +262,7 @@
|
||||
.navButtonNext {
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.navButtonNext::after {
|
||||
/* SVG de flecha derecha (chevron) codificado en Base64 */
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSI5IDYgMTUgMTIgOSAxOCI+PC9wb2x5bGluZT48L3N2Zz4=");
|
||||
@@ -285,8 +318,13 @@
|
||||
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-prev) {
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-next) {
|
||||
right: 10px !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-disabled) {
|
||||
opacity: 0 !important;
|
||||
@@ -294,10 +332,10 @@
|
||||
}
|
||||
|
||||
.widgetFooter {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
margin-top: 0.5rem;
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.shortText {
|
||||
@@ -306,17 +344,34 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.homeCarouselWidget .widgetHeader {
|
||||
/* Comportamiento por defecto en móvil: apilado y centrado */
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* NUEVA CLASE MODIFICADORA para los widgets con botón */
|
||||
.homeCarouselWidget .headerSingleLine {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .widgetTitle {
|
||||
text-align: left;
|
||||
font-size: 1rem;
|
||||
.homeCarouselWidget .widgetTitle {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
|
||||
/* Ajuste para que el título vuelva a la izquierda en la vista de una línea */
|
||||
.headerSingleLine .widgetTitle {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provinceSelector {
|
||||
min-width: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.mapLinkButton {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -325,31 +380,83 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.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: -10px; }
|
||||
.navButtonNext { right: -10px; }
|
||||
|
||||
.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; }
|
||||
.navButtonPrev {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
.navButtonNext {
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.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 */
|
||||
|
||||
@@ -58,7 +58,7 @@ export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={styles.widgetHeader}>
|
||||
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
|
||||
<h2 className={styles.widgetTitle}>{titulo}</h2>
|
||||
<a href={mapLinkUrl} className={styles.mapLinkButton}>
|
||||
<TfiMapAlt />
|
||||
|
||||
@@ -1,81 +1,107 @@
|
||||
/* src/components/widgets/ResumenNacionalWidget.module.css */
|
||||
.widgetContainer {
|
||||
font-family: sans-serif;
|
||||
border: 1px solid #ccc;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
max-width: 1000px;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subHeader h4 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.categoriaSelector {
|
||||
min-width: 280px;
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
.listaProvincias {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.resultsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.provinciaItem {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
.resultsTable thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.provinciaItem:last-child {
|
||||
border-bottom: none;
|
||||
.resultsTable td {
|
||||
padding: 3px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provinciaHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
.provinciaBlock {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.provinciaBlock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.provinciaNombre {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
color: #333;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.provinciaEscrutado {
|
||||
font-size: 0.8rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.resultadosLista {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resultadoItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-top: 1rem;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.partidoNombre {
|
||||
color: #333;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.partidoPorcentaje {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
/* --- INICIO DE ESTILOS PARA MÓVILES --- */
|
||||
@media (max-width: 768px) {
|
||||
.subHeader {
|
||||
flex-direction: column; /* Apila el título y el selector */
|
||||
align-items: center; /* Centra los elementos */
|
||||
gap: 0.75rem; /* Añade espacio entre ellos */
|
||||
}
|
||||
|
||||
.subHeader h4 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categoriaSelector {
|
||||
width: 100%; /* Hace que el selector ocupe todo el ancho */
|
||||
min-width: unset; /* Elimina el ancho mínimo que interfiere */
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/widgets/ResumenNacionalWidget.tsx
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getResumenNacionalPorProvincia } from '../../../apiService';
|
||||
@@ -11,6 +11,35 @@ const CATEGORIAS_NACIONALES = [
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
];
|
||||
|
||||
// 1. Mapa para definir el orden y número de cada provincia según el PDF
|
||||
const PROVINCE_ORDER_MAP: Record<string, number> = {
|
||||
'02': 1, // Buenos Aires
|
||||
'03': 2, // Catamarca
|
||||
'06': 3, // Chaco
|
||||
'07': 4, // Chubut
|
||||
'04': 5, // Córdoba
|
||||
'05': 6, // Corrientes
|
||||
'08': 7, // Entre Ríos
|
||||
'09': 8, // Formosa
|
||||
'10': 9, // Jujuy
|
||||
'11': 10, // La Pampa
|
||||
'12': 11, // La Rioja
|
||||
'13': 12, // Mendoza
|
||||
'14': 13, // Misiones
|
||||
'15': 14, // Neuquén
|
||||
'16': 15, // Río Negro
|
||||
'17': 16, // Salta
|
||||
'18': 17, // San Juan
|
||||
'19': 18, // San Luis
|
||||
'20': 19, // Santa Cruz
|
||||
'21': 20, // Santa Fe
|
||||
'22': 21, // Santiago del Estero
|
||||
'23': 22, // Tierra del Fuego
|
||||
'24': 23, // Tucumán
|
||||
'01': 24, // CABA
|
||||
};
|
||||
|
||||
|
||||
export const ResumenNacionalWidget = () => {
|
||||
const [categoria, setCategoria] = useState(CATEGORIAS_NACIONALES[0]);
|
||||
|
||||
@@ -20,12 +49,22 @@ export const ResumenNacionalWidget = () => {
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2)}%`;
|
||||
// 2. Ordenar los datos de la API usando el mapa de ordenamiento
|
||||
const sortedData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => {
|
||||
const orderA = PROVINCE_ORDER_MAP[a.provinciaId] ?? 99;
|
||||
const orderB = PROVINCE_ORDER_MAP[b.provinciaId] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.header}>
|
||||
<h3>{categoria.label}</h3>
|
||||
<div className={styles.subHeader}>
|
||||
<h4>{categoria.label}</h4>
|
||||
<Select
|
||||
className={styles.categoriaSelector}
|
||||
options={CATEGORIAS_NACIONALES}
|
||||
@@ -36,25 +75,30 @@ export const ResumenNacionalWidget = () => {
|
||||
</div>
|
||||
{isLoading && <p>Cargando resumen nacional...</p>}
|
||||
{error && <p style={{ color: 'red' }}>Error al cargar los datos.</p>}
|
||||
{data && (
|
||||
<ul className={styles.listaProvincias}>
|
||||
{data.map((provincia) => (
|
||||
<li key={provincia.provinciaId} className={styles.provinciaItem}>
|
||||
<div className={styles.provinciaHeader}>
|
||||
<span className={styles.provinciaNombre}>{provincia.provinciaNombre}</span>
|
||||
<span className={styles.provinciaEscrutado}>ESCR. {formatPercent(provincia.porcentajeEscrutado)}</span>
|
||||
</div>
|
||||
<ul className={styles.resultadosLista}>
|
||||
{provincia.resultados.map((partido, index) => (
|
||||
<li key={index} className={styles.resultadoItem}>
|
||||
<span className={styles.partidoNombre}>{partido.nombre}</span>
|
||||
<span className={styles.partidoPorcentaje}>{formatPercent(partido.porcentaje)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
{sortedData && (
|
||||
<table className={styles.resultsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Concepto</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{sortedData.map((provincia) => (
|
||||
<tbody key={provincia.provinciaId} className={styles.provinciaBlock}>
|
||||
<tr>
|
||||
{/* 3. Añadir el número antes del nombre */}
|
||||
<td className={styles.provinciaNombre}>{`${PROVINCE_ORDER_MAP[provincia.provinciaId]}- ${provincia.provinciaNombre}`}</td>
|
||||
<td className={styles.provinciaEscrutado}>ESCR. {formatPercent(provincia.porcentajeEscrutado)}</td>
|
||||
</tr>
|
||||
{provincia.resultados.map((partido, index) => (
|
||||
<tr key={index}>
|
||||
<td className={styles.partidoNombre}>{partido.nombre}</td>
|
||||
<td className={styles.partidoPorcentaje}>{formatPercent(partido.porcentaje)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</ul>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,13 +4,10 @@ import { getTablaConurbano } from '../../../apiService';
|
||||
import styles from './TablaResultadosWidget.module.css';
|
||||
|
||||
export const TablaConurbanoWidget = () => {
|
||||
// CORRECCIÓN: Se elimina el estado y el selector de categoría.
|
||||
const ELECCION_ID = 2; // Exclusivo para elecciones nacionales
|
||||
const ELECCION_ID = 2;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
// La queryKey ya no necesita la categoría
|
||||
queryKey: ['tablaConurbano', ELECCION_ID],
|
||||
// La llamada a la API ya no necesita la categoría
|
||||
queryFn: () => getTablaConurbano(ELECCION_ID),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
@@ -20,7 +17,7 @@ export const TablaConurbanoWidget = () => {
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.header}>
|
||||
<h3>Resultados Conurbano - Diputados Nacionales</h3>
|
||||
<h3>Diputados Nacionales</h3>
|
||||
</div>
|
||||
{isLoading && <p>Cargando resultados...</p>}
|
||||
{error && <p>Error al cargar los datos.</p>}
|
||||
@@ -38,7 +35,9 @@ export const TablaConurbanoWidget = () => {
|
||||
<tbody>
|
||||
{data.map((fila, index) => (
|
||||
<tr key={fila.ambitoId}>
|
||||
<td className={styles.distritoCell}><span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}</td>
|
||||
<td className={styles.distritoCell}>
|
||||
<span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}
|
||||
</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza1Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
|
||||
|
||||
@@ -73,4 +73,104 @@
|
||||
font-weight: 400;
|
||||
color: #6c757d;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- INICIO DE ESTILOS PARA MÓVILES --- */
|
||||
@media (max-width: 768px) {
|
||||
.widgetContainer {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.resultsTable thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resultsTable,
|
||||
.resultsTable tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 1. Cada TR es una grilla */
|
||||
.resultsTable tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
/* Columna para nombres, columna para % */
|
||||
grid-template-rows: auto auto auto;
|
||||
/* Fila para distrito, 1ra fuerza, 2da fuerza */
|
||||
gap: 4px 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.resultsTable tr:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resultsTable td {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 2. Posicionamos cada celda en la grilla */
|
||||
.distritoCell {
|
||||
grid-column: 1 / -1;
|
||||
/* Ocupa toda la primera fila */
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(2) {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.porcentajeCell:nth-of-type(3) {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(4) {
|
||||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.porcentajeCell:nth-of-type(5) {
|
||||
grid-row: 3;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* 3. Añadimos los labels "1ra:" y "2da:" con pseudo-elementos */
|
||||
.fuerzaCell::before {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(2)::before {
|
||||
content: '1ra:';
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(4)::before {
|
||||
content: '2da:';
|
||||
}
|
||||
|
||||
/* Ajustes de alineación */
|
||||
.fuerzaCell {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.porcentajeCell {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.seccionHeader td {
|
||||
display: block;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/features/legislativas/nacionales/TablaSeccionesWidget.tsx
|
||||
import React from 'react'; // Importar React para React.Fragment
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTablaSecciones } from '../../../apiService';
|
||||
import styles from './TablaResultadosWidget.module.css';
|
||||
@@ -18,7 +18,7 @@ export const TablaSeccionesWidget = () => {
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.header}>
|
||||
<h3>Resultados por Sección - Diputados Nacionales</h3>
|
||||
<h3>Diputados Nacionales</h3>
|
||||
</div>
|
||||
{isLoading && <p>Cargando resultados...</p>}
|
||||
{error && <p>Error al cargar los datos.</p>}
|
||||
@@ -41,7 +41,9 @@ export const TablaSeccionesWidget = () => {
|
||||
</tr>
|
||||
{seccion.municipios.map((fila, index) => (
|
||||
<tr key={fila.ambitoId}>
|
||||
<td className={styles.distritoCell}><span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}</td>
|
||||
<td className={styles.distritoCell}>
|
||||
<span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}
|
||||
</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza1Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { HomeCarouselNacionalWidget } from './features/legislativas/nacionales/H
|
||||
import { TablaConurbanoWidget } from './features/legislativas/nacionales/TablaConurbanoWidget';
|
||||
import { TablaSeccionesWidget } from './features/legislativas/nacionales/TablaSeccionesWidget';
|
||||
import { ResumenNacionalWidget } from './features/legislativas/nacionales/ResumenNacionalWidget';
|
||||
import { HomeCarouselProvincialWidget } from './features/legislativas/nacionales/HomeCarouselProvincialWidget';
|
||||
|
||||
import { DevAppLegislativas } from './features/legislativas/DevAppLegislativas';
|
||||
|
||||
@@ -68,6 +69,7 @@ const WIDGET_MAP: Record<string, React.ElementType> = {
|
||||
'tabla-conurbano': TablaConurbanoWidget,
|
||||
'tabla-secciones': TablaSeccionesWidget,
|
||||
'resumen-nacional': ResumenNacionalWidget,
|
||||
'home-carousel-provincial': HomeCarouselProvincialWidget,
|
||||
};
|
||||
|
||||
// Vite establece `import.meta.env.DEV` a `true` cuando ejecutamos 'npm run dev'
|
||||
|
||||
Reference in New Issue
Block a user