Feat Widgets

- Widget de Home
- Widget Cards por Provincias
- Widget Mapa por Categorias
This commit is contained in:
2025-10-01 10:03:01 -03:00
parent 3b0eee25e6
commit a985cbfd7c
45 changed files with 1786 additions and 953 deletions

View File

@@ -0,0 +1,239 @@
/* src/features/legislativas/nacionales/HomeCarouselWidget.css */
.home-carousel-widget {
--primary-text: #212529;
--secondary-text: #6c757d;
--border-color: #dee2e6;
--background-light: #f8f9fa;
--background-white: #ffffff;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
--font-family-sans: "Roboto", system-ui, sans-serif;
}
.home-carousel-widget {
font-family: var(--font-family-sans);
background-color: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
max-width: 1200px;
margin: 2rem auto;
}
.widget-title {
font-size: 1.2rem;
font-weight: 900;
color: var(--primary-text);
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.top-stats-bar {
display: flex;
justify-content: space-around;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.3rem 0.5rem;
margin-bottom: 0.5rem;
}
.top-stats-bar > div {
display: flex;
align-items: baseline;
gap: 0.5rem;
border-right: 1px solid var(--border-color);
padding: 0 0.5rem;
flex-grow: 1;
justify-content: center;
}
.top-stats-bar > div:last-child { border-right: none; }
.top-stats-bar span { font-size: 0.9rem; color: var(--secondary-text); }
.top-stats-bar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); }
.candidate-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem;
box-shadow: var(--shadow);
height: 100%;
border-left: 5px solid;
border-left-color: var(--candidate-color, #ccc);
position: relative;
}
.candidate-photo-wrapper {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background-color: var(--candidate-color, #e9ecef);
}
.candidate-photo {
width: 100%;
height: 100%;
object-fit: cover;
box-sizing: border-box;
}
.candidate-details {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
}
.candidate-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items:flex-start;
gap: 0.1rem;
min-width: 0;
margin-right: 0.75rem;
}
.candidate-name, .party-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
}
.candidate-name {
font-size: 0.95rem;
text-align: left;
font-weight: 700;
color: var(--primary-text);
}
.party-name {
font-size: 0.8rem;
text-align: left;
text-transform: uppercase;
color: var(--secondary-text);
text-transform: uppercase;
}
.candidate-results { text-align: right; flex-shrink: 0; }
.percentage {
display: block;
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-text);
line-height: 1.1;
}
.votes {
font-size: 0.75rem;
color: var(--secondary-text);
white-space: nowrap;
}
.swiper-slide:not(:last-child) .candidate-card::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}
.swiper-button-prev, .swiper-button-next {
width: 30px; height: 30px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
color: var(--secondary-text);
}
.swiper-button-prev:after, .swiper-button-next:after {
font-size: 18px;
font-weight: bold;
}
.swiper-button-prev { left: -10px; }
.swiper-button-next { right: -10px; }
.swiper-button-disabled { opacity: 0; pointer-events: none; }
.widget-footer {
text-align: right;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.5rem;
}
.short-text {
display: none; /* Oculto por defecto en la vista de escritorio */
}
/* --- INICIO DE LA SECCIÓN DE ESTILOS PARA MÓVIL --- */
@media (max-width: 768px) {
.home-carousel-widget {
padding: 0.75rem;
}
/* 1. Centrar el título en móvil */
.widget-title {
text-align: center;
font-size: 1.1rem;
}
/* 2. Reestructurar la barra de estadísticas a 2x2 y usar textos cortos */
.top-stats-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.2rem;
padding: 0.3rem;
}
.top-stats-bar > div {
padding: 0.25rem 0.5rem;
border-right: none; /* Quitar todos los bordes derechos */
}
.top-stats-bar > div:nth-child(odd) {
border-right: 1px solid var(--border-color); /* Restablecer borde solo para la columna izquierda */
}
/* Lógica de visibilidad de textos */
.long-text {
display: none; /* Ocultar el texto largo en móvil */
}
.short-text {
display:inline; /* Mostrar el texto corto en móvil */
}
/* Reducir fuentes para que quepan mejor */
.top-stats-bar span { font-size: 0.8rem; text-align: left; }
.top-stats-bar strong { font-size: 0.85rem; text-align: right;}
/* --- Botones del Carrusel (sin cambios) --- */
.swiper-button-prev, .swiper-button-next {
width: 32px;
height: 32px;
top: 45%;
}
.swiper-button-prev { left: 2px; }
.swiper-button-next { right: 2px; }
/* --- Ajustes en la tarjeta (sin cambios) --- */
.candidate-card { gap: 0.5rem; padding: 0.5rem; }
.candidate-photo-wrapper { width: 50px; height: 50px; }
.candidate-name { font-size: 0.9rem; }
.percentage { font-size: 1.1rem; }
.votes { font-size: 0.7rem; }
/* 3. Centrar el footer en móvil */
.widget-footer {
text-align: center;
}
}

View File

@@ -0,0 +1,135 @@
// src/features/legislativas/nacionales/HomeCarouselWidget.tsx
import { useQuery } from '@tanstack/react-query';
import { getHomeResumen } from '../../../apiService';
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 './HomeCarouselWidget.css';
interface Props {
eleccionId: number;
distritoId: string;
categoriaId: number;
titulo: string;
}
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const formatNumber = (num: number) => num.toLocaleString('es-AR');
// --- Lógica de formateo de fecha ---
const formatDateTime = (dateString: string | undefined | null) => {
if (!dateString) return '...';
try {
const date = new Date(dateString);
// Verificar si la fecha es válida
if (isNaN(date.getTime())) {
return dateString; // Si no se puede parsear, devolver el string original
}
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; // En caso de cualquier error, devolver el string original
}
};
export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo }: Props) => {
const { data, isLoading, error } = useQuery({
queryKey: ['homeResumen', eleccionId, distritoId, categoriaId],
queryFn: () => getHomeResumen(eleccionId, distritoId, categoriaId),
});
if (isLoading) return <div>Cargando widget...</div>;
if (error || !data) return <div>No se pudieron cargar los datos.</div>;
return (
<div className="home-carousel-widget">
<h2 className="widget-title">{titulo}</h2>
<div className="top-stats-bar">
<div>
<span>Participación</span>
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
</div>
<div>
<span className="long-text">Mesas escrutadas</span>
<span className="short-text">Escrutado</span>
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
</div>
<div>
<span className="long-text">Votos en blanco</span>
<span className="short-text">En blanco</span>
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
</div>
<div>
<span className="long-text">Votos totales</span>
<span className="short-text">Votos</span>
<strong>{formatNumber(data.votosTotales)}</strong>
</div>
</div>
<Swiper
modules={[Navigation, A11y]}
spaceBetween={16}
slidesPerView={1.15}
navigation
breakpoints={{ 640: { slidesPerView: 2 }, 1024: { slidesPerView: 3 }, 1200: { slidesPerView: 3.5 } }} // Añadir breakpoint
>
{data.resultados.map(candidato => (
<SwiperSlide key={candidato.agrupacionId}>
<div className="candidate-card" style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
<div className="candidate-photo-wrapper">
<ImageWithFallback
src={candidato.fotoUrl ?? undefined}
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
alt={candidato.nombreCandidato ?? ''}
className="candidate-photo"
/>
</div>
<div className="candidate-details">
<div className="candidate-info">
{candidato.nombreCandidato ? (
// CASO 1: Hay un candidato (se muestran dos líneas)
<>
<span className="candidate-name">
{candidato.nombreCandidato}
</span>
<span className="party-name">
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
</span>
</>
) : (
// CASO 2: No hay candidato (se muestra solo una línea)
<span className="candidate-name">
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
</span>
)}
</div>
<div className="candidate-results">
<span className="percentage">{formatPercent(candidato.porcentaje)}</span>
<span className="votes">{formatNumber(candidato.votos)} votos</span>
</div>
</div>
</div>
</SwiperSlide>
))}
</Swiper>
<div className="widget-footer">
Última actualización: {formatDateTime(data.ultimaActualizacion)}
</div>
</div>
);
};

View File

@@ -22,14 +22,9 @@
/* Contenedor para alinear título y selector */
.header-top-row {
display: flex;
justify-content: space-between;
justify-content: flex-start; /* Alinea los items al inicio */
align-items: center;
margin-bottom: 0.5rem;
}
.panel-header h1 {
font-size: 1.5rem;
margin: 0;
gap: 2rem; /* Añade un espacio de separación de 2rem entre el selector y el breadcrumb */
}
.categoria-selector {
@@ -188,6 +183,7 @@
padding: 1rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 1rem;
}
@@ -227,18 +223,25 @@
.partido-info-wrapper {
/* Ocupa el espacio disponible a la izquierda */
min-width: 0;
text-align: left;
}
.partido-nombre {
font-weight: 800;
font-weight: 700;
font-size: 1.05rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.candidato-nombre {
font-size: 0.85rem;
color: #666;
font-size: 0.8rem;
color: #6c757d;
text-transform: uppercase;
font-weight: 500;
line-height: 1.1;
}
.partido-stats {
@@ -381,10 +384,13 @@
}
.rsm-geography:not(.selected):hover {
filter: brightness(1.25); /* Mantenemos el brillo */
stroke: #ffffff; /* Color del borde a blanco */
filter: brightness(1.25);
/* Mantenemos el brillo */
stroke: #ffffff;
/* Color del borde a blanco */
stroke-width: 0.25px;
paint-order: stroke; /* Asegura que el borde se dibuje encima del relleno */
paint-order: stroke;
/* Asegura que el borde se dibuje encima del relleno */
}
.rsm-geography.selected {
@@ -492,8 +498,10 @@
/* --- 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 */
position: absolute;
/* <-- CAMBIO: De 'fixed' a 'absolute' */
bottom: 10px;
/* <-- AJUSTE: Menos espacio desde abajo */
left: 50%;
transform: translateX(-50%);
z-index: 100;
@@ -685,6 +693,14 @@
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 {

View File

@@ -10,6 +10,7 @@ import Select from 'react-select';
import type { PanelElectoralDto } from '../../../types/types';
import { FiMap, FiList } from 'react-icons/fi';
import { useMediaQuery } from './hooks/useMediaQuery';
import { Toaster } from 'react-hot-toast';
interface PanelNacionalWidgetProps {
eleccionId: number;
@@ -79,9 +80,9 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
return (
<div className="panel-nacional-container">
<Toaster containerClassName="widget-toaster-container" />
<header className="panel-header">
<div className="header-top-row">
<h1>Legislativas Argentina 2025</h1>
<Select
options={CATEGORIAS_NACIONALES}
value={selectedCategoria}
@@ -90,14 +91,14 @@ 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>
<Breadcrumbs
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvincia={ambitoActual.provinciaNombre}
onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia}
/>
</header>
<main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''} ${isMobile ? `mobile-view-${mobileView}` : ''}`}>
<div className="mapa-column">

View File

@@ -8,7 +8,7 @@
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--text-primary: #212529;
--text-secondary: #6c757d;
--font-family: "Public Sans", system-ui, sans-serif;
--font-family: "Roboto", system-ui, sans-serif;
--primary-accent-color: #007bff;
}
@@ -34,6 +34,7 @@
/* Crea columnas flexibles que se ajustan al espacio disponible */
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem;
align-items: start;
}
/* --- Tarjeta Individual --- */
@@ -110,6 +111,9 @@
gap: 0.75rem;
padding: 0.75rem 0;
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 */
}
.candidato-row:last-child {
@@ -117,9 +121,9 @@
}
.candidato-foto {
width: 45px;
height: 45px;
border-radius: 50%;
width: 60px;
height: 60px;
border-radius: 5%;
object-fit: cover;
flex-shrink: 0;
}
@@ -135,6 +139,7 @@
font-size: 0.95rem;
color: var(--text-primary);
display: block;
text-align: left;
}
.candidato-partido {
@@ -143,10 +148,11 @@
text-transform: uppercase;
display: block;
margin-bottom: 0.3rem;
text-align: left;
}
.progress-bar-container {
height: 6px;
height: 16px;
background-color: #e9ecef;
border-radius: 3px;
overflow: hidden;
@@ -249,11 +255,49 @@
}
}
/* --- NUEVOS ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */
/* --- ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */
.candidato-partido.main-title {
font-size: 0.95rem; /* Hacemos la fuente más grande */
font-weight: 700; /* La ponemos en negrita, como el nombre del candidato */
color: var(--text-primary); /* Usamos el color de texto principal */
text-transform: none; /* Quitamos el 'uppercase' para que se lea mejor */
margin-bottom: 0.3rem;
}
/* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */
.categoria-bloque {
width: 100%;
}
/* Añadimos un separador si hay más de una categoría en la misma tarjeta */
.categoria-bloque + .categoria-bloque {
border-top: 1px dashed var(--card-border-color);
margin-top: 1rem;
padding-top: 1rem;
}
.categoria-titulo {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
text-align: center;
margin: 0 0 1rem 0;
}
/* Ajuste para el footer, que ahora está dentro de cada categoría */
.categoria-bloque .card-footer {
grid-template-columns: repeat(3, 1fr);
background-color: transparent; /* Quitamos el fondo gris */
border-top: 1px solid var(--card-border-color);
padding: 0.75rem 0;
margin-top: 0.75rem; /* Espacio antes del footer */
text-align: center;
}
.categoria-bloque .card-footer div {
border-right: 1px solid var(--card-border-color);
}
.categoria-bloque .card-footer div:last-child {
border-right: none;
}

View File

@@ -4,25 +4,47 @@ import { getResumenPorProvincia } from '../../../apiService';
import { ProvinciaCard } from './components/ProvinciaCard';
import './ResultadosNacionalesCardsWidget.css';
// --- 1. AÑADIR LA PROP A LA INTERFAZ ---
interface Props {
eleccionId: number;
focoDistritoId?: string;
focoCategoriaId?: number;
cantidadResultados?: number;
mostrarBancas?: boolean; // Booleano opcional
}
export const ResultadosNacionalesCardsWidget = ({ eleccionId }: Props) => {
// --- 2. RECIBIR LA PROP Y ESTABLECER UN VALOR POR DEFECTO ---
export const ResultadosNacionalesCardsWidget = ({
eleccionId,
focoDistritoId,
focoCategoriaId,
cantidadResultados,
mostrarBancas = false // Por defecto, no se muestran las bancas
}: Props) => {
const { data, isLoading, error } = useQuery({
queryKey: ['resumenPorProvincia', eleccionId],
queryFn: () => getResumenPorProvincia(eleccionId),
queryKey: ['resumenPorProvincia', eleccionId, focoDistritoId, focoCategoriaId, cantidadResultados],
queryFn: () => getResumenPorProvincia(eleccionId, {
focoDistritoId,
focoCategoriaId,
cantidadResultados
}),
});
if (isLoading) return <div>Cargando resultados por provincia...</div>;
if (error) return <div>Error al cargar los datos.</div>;
if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div>
return (
<section className="cards-widget-container">
<h2>Resultados elecciones nacionales 2025</h2>
<div className="cards-grid">
{data?.map(provinciaData => (
<ProvinciaCard key={provinciaData.provinciaId} data={provinciaData} />
<ProvinciaCard
key={provinciaData.provinciaId}
data={provinciaData}
mostrarBancas={mostrarBancas}
/>
))}
</div>
</section>

View File

@@ -11,6 +11,7 @@ import type { ResultadoMapaDto, AmbitoGeography } from '../../../../types/types'
import { MapaProvincial } from './MapaProvincial';
import { CabaLupa } from './CabaLupa';
import { BiZoomIn, BiZoomOut } from "react-icons/bi";
import toast from 'react-hot-toast';
const DEFAULT_MAP_COLOR = '#E0E0E0';
const FADED_BACKGROUND_COLOR = '#F0F0F0';
@@ -166,9 +167,45 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
};
}, [position, nivel]);
const handleZoomIn = () => setPosition(prev => ({ ...prev, zoom: Math.min(prev.zoom * 1.8, 100) }));
const panEnabled =
nivel === 'provincia' &&
initialProvincePositionRef.current !== null &&
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: '🖐️',
style: { background: '#32e5f1ff', color: 'white' },
duration: 1000,
});
}
}
setPosition(prev => ({ ...prev, zoom: Math.min(prev.zoom * 1.8, 100) }));
};
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: '🔒',
style: { background: '#32e5f1ff', color: 'white' },
duration: 1000,
});
}
}
// 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;
@@ -182,12 +219,6 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
setIsPanning(false);
};
const panEnabled =
nivel === 'provincia' &&
initialProvincePositionRef.current !== null &&
position.zoom > initialProvincePositionRef.current.zoom &&
!nombreMunicipioSeleccionado;
const filterInteractionEvents = (event: any) => {
if (event.sourceEvent && event.sourceEvent.type === 'wheel') return false;
return panEnabled;

View File

@@ -71,15 +71,25 @@ export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosP
<div className="panel-partidos-container">
{resultados.map(partido => (
<div key={partido.id} className="partido-fila" style={{ borderLeftColor: partido.color || '#888' }}>
<div
key={partido.id}
className="partido-fila"
style={{ borderLeftColor: partido.color || '#ccc' }}
>
<div className="partido-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div>
<div className="partido-main-content">
<div className="partido-top-row">
<div className="partido-info-wrapper">
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
{partido.nombreCandidato && <span className="candidato-nombre">{partido.nombreCandidato}</span>}
{partido.nombreCandidato ? (
<>
<span className="candidato-nombre">{partido.nombreCandidato}</span>
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
</>
) : (
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
)}
</div>
<div className="partido-stats">
<span className="partido-porcentaje">

View File

@@ -1,78 +1,110 @@
// src/features/legislativas/nacionales/components/ProvinciaCard.tsx
import type { ResumenProvincia } from '../../../../types/types';
import type { ResumenProvincia, CategoriaResumen } from '../../../../types/types';
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;
}
interface ProvinciaCardProps {
data: ResumenProvincia;
mostrarBancas?: boolean;
}
const formatNumber = (num: number) => num.toLocaleString('es-AR');
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
export const ProvinciaCard = ({ data }: ProvinciaCardProps) => {
// Determinamos el color del ganador para pasárselo al mapa.
// Si no hay ganador, usamos un color gris por defecto.
const colorGanador = data.resultados[0]?.color || '#d1d1d1';
// --- 2. RECIBIR Y USAR LA PROP EN EL SUB-COMPONENTE ---
const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => {
return (
<div className="categoria-bloque">
<h4 className="categoria-titulo">{categoria.categoriaNombre}</h4>
{categoria.resultados.map(res => (
<div
key={res.agrupacionId}
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"
/>
<div className="candidato-data">
{res.nombreCandidato && (
<span className="candidato-nombre">{res.nombreCandidato}</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>
</div>
<div className="candidato-stats">
<span className="stats-percent">{formatPercent(res.porcentaje)}</span>
<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}
<span>Bancas</span>
</div>
)}
</div>
))}
<footer className="card-footer">
<div>
<span>Participación</span>
<strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Mesas escrutadas</span>
<strong>{formatPercent(categoria.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Votos totales</span>
<strong>{formatNumber(categoria.estadoRecuento?.cantidadVotantes ?? 0)}</strong>
</div>
</footer>
</div>
);
};
// --- 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';
return (
<div className="provincia-card">
<header className="card-header">
<div className="header-info">
<h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3>
<span>DIPUTADOS NACIONALES</span>
</div>
<div className="header-map">
<MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} />
</div>
</header>
<div className="card-body">
{data.resultados.map(res => (
<div key={res.agrupacionId} className="candidato-row">
<ImageWithFallback src={res.fotoUrl ?? undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={res.nombreCandidato ?? res.nombreAgrupacion} className="candidato-foto" />
<div className="candidato-data">
{res.nombreCandidato && (
<span className="candidato-nombre">{res.nombreCandidato}</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>
</div>
<div className="candidato-stats">
<span className="stats-percent">{formatPercent(res.porcentaje)}</span>
<span className="stats-votos">{formatNumber(res.votos)} votos</span>
</div>
<div className="stats-bancas">
+{res.bancasObtenidas}
<span>Bancas</span>
</div>
</div>
{data.categorias.map(categoria => (
<CategoriaDisplay
key={categoria.categoriaId}
categoria={categoria}
mostrarBancas={mostrarBancas} // Pasar la prop hacia abajo
/>
))}
</div>
<footer className="card-footer">
<div>
<span>Participación</span>
{/* Usamos los datos reales del estado de recuento */}
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Mesas escrutadas</span>
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Votos totales</span>
{/* Usamos el nuevo campo cantidadVotantes */}
<strong>{formatNumber(data.estadoRecuento?.cantidadVotantes ?? 0)}</strong>
</div>
</footer>
</div>
);
};