Compare commits

69 Commits

Author SHA1 Message Date
63cc5ecec8 Feat Se Añade Sondeo Resultados Nivel 50 2025-10-27 17:02:22 -03:00
3a43c4a74a Fix bootstrap.js
- Cambios en bootstrap.js para solucionar cargas inestables en eldia.com versióm movil.
2025-10-27 12:25:30 -03:00
ef1c1e41dc Fix Añadida la Clase noAjax a Botón Mapa 2025-10-27 11:37:17 -03:00
c36f4b6153 Fix refetchInterval 180.000 ms 2025-10-26 21:51:04 -03:00
99406d10ee Feat Agrupación de Partidos por Alianzas
- Se permite la agrupación por alianzas en las vistas Nación.
2025-10-26 18:34:55 -03:00
8d7f5c1db6 Fix Nombre CABA 2025-10-24 12:28:33 -03:00
21002445b2 Fix Alto de Lista de Selector Carousel 2025-10-24 12:19:00 -03:00
70069d46f7 Fix Conversión Categoria en Carousel
- Se realiza conversión del id de la categoria para que funcione la condición del selector de provincias.
2025-10-24 12:14:12 -03:00
ad883257a3 Feat Widgets Carousel Selector de Porv. Fix Tablas Movil 2025-10-24 11:46:37 -03:00
1335b54d75 Fix Home Widget Styles 2025-10-23 18:19:13 -03:00
983ed5e39c Feat Botón Map URL Parámetro Widget Carousel 2025-10-23 15:34:09 -03:00
e98e152f0e Feat Tabla Resumen Nacional 2025-10-23 14:01:20 -03:00
248171146d Fix Widgets Nuevos Añadidos a Disponibilidad 2025-10-23 12:44:40 -03:00
4dbda0da63 Feat Tabla de Datos Redaccion
- Tabla de Top 2 Conurbano
- Tabla de Top 2 Por Secciones Electorales Bs. As.
2025-10-23 12:31:10 -03:00
3c364ef373 Fix Widgets Home y Logos Overrides 2025-10-23 10:51:50 -03:00
814b24cefb Fix Overrides Logos Y Candidatos 2025-10-22 15:53:40 -03:00
f89903feda Fix distritoId Senadores 2025-10-22 13:48:05 -03:00
0ee092d6ed Fix Distritos Para Senadores 2025-10-22 13:09:28 -03:00
db469ffba6 Fix Bacas - Itera Sobre Cada Provincia Y Categoría 2025-10-22 11:21:48 -03:00
5ef3eb1af2 Fix Llamadas de Ambitos Para Resultados 2025-10-22 10:29:02 -03:00
bea752f7d0 Fix Llamada a getResultados 2025-10-21 18:51:22 -03:00
a0e587d8b5 Fix Resultados Totales Nacionales 2025-10-21 18:32:53 -03:00
ced1ae6b3f Fix Iteración por Provincias en Catálogo Maestro 2025-10-21 18:16:54 -03:00
c5c1872ab8 Fix Nombre Municipio 2025-10-21 13:56:07 -03:00
c50e4210b5 Fix Controllers Resultados 2025-10-21 11:03:44 -03:00
4cefb833d9 Fix Leyenda 2025-10-20 15:07:02 -03:00
a78fcf66c0 feat: Partido Politico Manual 2025-10-20 14:38:10 -03:00
99d56033b1 Fix Bancas Previas 2025-10-20 13:24:17 -03:00
5c11763386 Fix: Refresh de Datos Widgets 2025-10-20 12:45:49 -03:00
9cd91581bf Fix Nombres de Ámbitos en .topojson 2025-10-20 12:30:39 -03:00
d6b4c3cc4d Feat Se añade Id de Agrupaciones en Componentes 2025-10-20 11:03:19 -03:00
069446b903 Fix Mapa Municipios - Limpieza y Optimización de Workers 2025-10-18 21:27:30 -03:00
2b7fb927e2 Fix Consulta de Bancas
- Se elimina la iteración sobre las secciones para la consulta de bancas.
2025-10-18 19:31:41 -03:00
705683861c Fix Catálogo Maestro de Agrupaciones Políticas
- Se remueve la iteración sobre distritos. Se consulta solo por categorías electorales.
2025-10-18 18:40:23 -03:00
17a5b333fd Fix Widgets Carousel 2025-10-18 10:51:51 -03:00
ae846f2d48 Feat CarouselNacional y Fix Workers 2025-10-17 15:49:15 -03:00
4bc257df43 Fix CategoriaId Y Error Msg 2025-10-17 14:05:44 -03:00
6892252a9b Fix Zoom Municipio Seleccionado 2025-10-17 13:59:07 -03:00
92c80f195b Fix Mapa Error (Sección Sin Datos) 2025-10-17 13:55:38 -03:00
45421f5c5f Fix Estilos Componentes 2025-10-17 13:23:47 -03:00
903c2b6a94 Fix Captura de Datos Bancas 2025-10-17 12:13:45 -03:00
7317c06650 Fix CABA y Nombres Comunas 2025-10-17 11:18:48 -03:00
fca65edefc Fix Categorias y Captura de Bancas 2025-10-17 10:12:12 -03:00
6cd09343f2 Fix EleccionId Workers 2025-10-16 15:46:44 -03:00
09c4d61b71 Fix 1 Test 1534 2025-10-16 15:34:12 -03:00
705a6f0f5e Feat Separación de Votos Inválidos 2025-10-15 11:44:22 -03:00
316f49f25b feat(Worker): Adaptación integral para la API de Elecciones Nacionales
Este commit refactoriza por completo el sistema de recolección de datos para asegurar la compatibilidad con la nueva API nacional, pasando de un modelo de distrito único a uno multi-distrito.

Cambios principales:

- **Refactorización de `SondearResumenProvincialAsync`:**
  - Se elimina la dependencia del endpoint obsoleto `/getResumen`.
  - El método ahora itera sobre todas las provincias (`NivelId=10`) y categorías, utilizando `GetResultadosAsync` para obtener los datos agregados.

- **Expansión de `SondearResultadosMunicipalesAsync`:**
  - Se renombra a `SondearResultadosPorAmbitosAsync` para reflejar su nueva responsabilidad.
  - La lógica ahora sondea múltiples niveles jerárquicos (`NivelId` 10, 20, 30), capturando resultados detallados para Provincias, Secciones Electorales y Municipios.

- **Modificación del Modelo de Datos:**
  - Se añade la columna `CategoriaId` a la entidad y tabla `ResumenVoto`.
  - Se crea la migración de base de datos `AddCategoriaIdToResumenVoto` para aplicar el cambio.

- **Ajustes de Nulabilidad en API Service:**
  - Se actualizan las firmas de `GetResultadosAsync` en `IElectoralApiService` y `ElectoralApiService` para permitir que `seccionId` y `municipioId` sean nulables (`string?`), resolviendo errores de compilación CS8625.

- **Deshabilitación de Seeders de Ejemplo:**
  - Se introduce una bandera `generarDatosDeEjemplo` en `Program.cs` de la API, establecida en `false`, para prevenir la ejecución de código de simulación en entornos de producción o pruebas.
2025-10-14 16:00:55 -03:00
84f7643907 Fix Nombre ISlas Malvinas 2025-10-09 09:41:24 -03:00
2736301338 Feat: Mapa Styles 2025-10-06 14:20:15 -03:00
a316e5dd08 Fix Styles CSS 2025-10-06 12:25:12 -03:00
ce4fc52d4a refactor: Migra todos los widgets nacionales a CSS Modules para encapsular estilos
Esta refactorización modifica la forma en que los widgets manejan sus estilos para prevenir conflictos con los CSS de los sitios anfitriones donde se incrustan.

Se ha migrado el sistema de estilos de CSS global a CSS Modules para todos los componentes principales y sus hijos, asegurando que todas las clases sean únicas y estén aisladas.

Cambios principales:
- Se actualizan los componentes .tsx para importar y usar los módulos de estilos (`import styles from ...`).
- Se renombran los archivos `.css` a `.module.css`.
- Se añade una regla en cada módulo para proteger la `font-family` y el `box-sizing` del widget, evitando que sean sobreescritos por estilos externos.
- Se ajustan los selectores para librerías de terceros (react-select, react-simple-maps) usando `:global()` para mantener la compatibilidad.
- Se mueven las variables CSS de `:root` a las clases principales de cada widget para evitar colisiones en el scope global.

Como resultado, los widgets (`HomeCarouselWidget`, `PanelNacionalWidget`, `ResultadosNacionalesCardsWidget`, `CongresoNacionalWidget`) son ahora más robustos, portátiles y visualmente consistentes en cualquier entorno.
2025-10-04 20:41:23 -03:00
fa261ba828 Fix Candidato Partido Estilos Mapa 2025-10-03 15:28:51 -03:00
3c8c4917fd Fix Mapa Style 2025-10-03 15:02:57 -03:00
68f31f2873 Fix Map Control Styles 2025-10-03 14:40:06 -03:00
9e0e7f0ee6 Fix Mapa Res Intermedia 2025-10-03 13:53:11 -03:00
b8c8c1260d Fix Icono Expandible y Top 2025-10-03 13:29:58 -03:00
64d45a7a39 Feat Widgets Controles y Estilos 2025-10-03 13:26:20 -03:00
1719e79723 Fix Styles 2025-10-02 13:38:28 -03:00
e0755a5347 Fix Css 2025-10-02 12:14:45 -03:00
e9b0eeb630 Fix to Prod 2025-10-01 11:59:15 -03:00
63cc042eb4 Merge branch 'Legislativas-Nacionales-2025'
# Conflicts:
#	Elecciones-Web/src/Elecciones.Api/obj/Debug/net9.0/Elecciones.Api.AssemblyInfo.cs
#	Elecciones-Web/src/Elecciones.Core/obj/Debug/net9.0/Elecciones.Core.AssemblyInfo.cs
2025-10-01 10:29:52 -03:00
ed5b78e6c8 Feat Visual en Producción 2025-10-01 10:27:30 -03:00
a985cbfd7c Feat Widgets
- Widget de Home
- Widget Cards por Provincias
- Widget Mapa por Categorias
2025-10-01 10:03:01 -03:00
3b0eee25e6 Feat Widgets Cards y Optimización de Consultas 2025-09-28 19:04:09 -03:00
67634ae947 Fix Panel de Resultados 2025-09-22 17:56:04 -03:00
5a8bee52d5 Fix Arrastre Mapa en Zoom 2025-09-22 09:08:43 -03:00
3750d1a56d Refinamiento de Funciones y Estética de Mapa 2025-09-20 22:31:11 -03:00
7d2922aaeb Pre Refinamiento Movil 2025-09-19 17:19:10 -03:00
3a8f64bf85 Preparación Legislativas Nacionales 2025 2025-09-17 11:31:17 -03:00
198 changed files with 14749 additions and 2681 deletions

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ build/
*.userprefs
/bin/
/obj/
/debug/
project.lock.json
project.assets.json
/packages/

View File

@@ -0,0 +1,79 @@
// src/components/AddAgrupacionForm.tsx
import { useState } from 'react';
import { createAgrupacion } from '../services/apiService';
import type { CreateAgrupacionData } from '../services/apiService';
// Importa el nuevo archivo CSS si lo creaste, o el existente
import './FormStyles.css';
interface Props {
onSuccess: () => void;
}
export const AddAgrupacionForm = ({ onSuccess }: Props) => {
const [nombre, setNombre] = useState('');
const [nombreCorto, setNombreCorto] = useState('');
const [color, setColor] = useState('#000000');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!nombre.trim()) {
setError('El nombre es obligatorio.');
return;
}
setIsLoading(true);
setError('');
const payload: CreateAgrupacionData = {
nombre: nombre.trim(),
nombreCorto: nombreCorto.trim() || null,
color: color,
};
try {
await createAgrupacion(payload);
alert(`Partido '${payload.nombre}' creado con éxito.`);
// Limpiar formulario
setNombre('');
setNombreCorto('');
setColor('#000000');
// Notificar al componente padre para que refresque los datos
onSuccess();
} catch (err: any) {
const errorMessage = err.response?.data?.message || 'Ocurrió un error inesperado.';
setError(errorMessage);
console.error(err);
} finally {
setIsLoading(false);
}
};
return (
<div className="add-entity-form-container">
<h4>Añadir Partido Manualmente</h4>
<form onSubmit={handleSubmit} className="add-entity-form">
<div className="form-field">
<label>Nombre Completo</label>
<input type="text" value={nombre} onChange={e => setNombre(e.target.value)} required />
</div>
<div className="form-field">
<label>Nombre Corto</label>
<input type="text" value={nombreCorto} onChange={e => setNombreCorto(e.target.value)} />
</div>
<div className="form-field">
<label>Color</label>
<input type="color" value={color} onChange={e => setColor(e.target.value)} />
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Guardando...' : 'Guardar Partido'}
</button>
</form>
{error && <p style={{ color: 'red', marginTop: '0.5rem', textAlign: 'left' }}>{error}</p>}
</div>
);
};

View File

@@ -36,6 +36,23 @@ td button {
margin-right: 5px;
}
.table-container {
max-height: 500px; /* Altura máxima antes de que aparezca el scroll */
overflow-y: auto; /* Habilita el scroll vertical cuando es necesario */
border: 1px solid #ddd;
border-radius: 4px;
position: relative; /* Necesario para que 'sticky' funcione correctamente */
}
/* Hacemos que la cabecera de la tabla se quede fija en la parte superior */
.table-container thead th {
position: sticky;
top: 0;
z-index: 1;
/* El color de fondo es crucial para que no se vea el contenido que pasa por debajo */
background-color: #f2f2f2;
}
.sortable-list-horizontal {
list-style: none;
padding: 8px;

View File

@@ -1,174 +1,154 @@
// src/components/AgrupacionesManager.tsx
// EN: src/components/AgrupacionesManager.tsx
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Select from 'react-select';
import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService';
import type { AgrupacionPolitica, LogoAgrupacionCategoria } from '../types';
import type { AgrupacionPolitica, LogoAgrupacionCategoria, UpdateAgrupacionData } from '../types';
import { AddAgrupacionForm } from './AddAgrupacionForm';
import './AgrupacionesManager.css';
const SENADORES_ID = 5;
const DIPUTADOS_ID = 6;
const CONCEJALES_ID = 7;
const GLOBAL_ELECTION_ID = 0;
const ELECCION_OPTIONS = [
{ value: GLOBAL_ELECTION_ID, label: 'Global (Logo por Defecto)' },
{ value: 2, label: 'Elecciones Nacionales (Override General)' },
{ value: 1, label: 'Elecciones Provinciales (Override General)' }
];
// Esta función limpia cualquier carácter no válido de un string de color.
const sanitizeColor = (color: string | null | undefined): string => {
if (!color) return '#000000'; // Devuelve un color válido por defecto si es nulo
// Usa una expresión regular para eliminar todo lo que no sea un '#' o un carácter hexadecimal
const sanitized = color.replace(/[^#0-9a-fA-F]/g, '');
return sanitized.startsWith('#') ? sanitized : `#${sanitized}`;
if (!color) return '#000000';
return color.startsWith('#') ? color : `#${color}`;
};
export const AgrupacionesManager = () => {
const queryClient = useQueryClient();
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, { nombreCorto: string | null; color: string | null; }>>({});
const [editedLogos, setEditedLogos] = useState<Record<string, string | null>>({});
const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, Partial<AgrupacionPolitica>>>({});
const [editedLogos, setEditedLogos] = useState<LogoAgrupacionCategoria[]>([]);
// Query 1: Obtener agrupaciones
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'],
queryFn: getAgrupaciones,
queryKey: ['agrupaciones'], queryFn: getAgrupaciones,
});
// Query 2: Obtener logos
const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({
queryKey: ['logos'],
queryFn: getLogos,
queryKey: ['allLogos'],
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
});
useEffect(() => {
// Solo procedemos si los datos de agrupaciones están disponibles
if (agrupaciones && agrupaciones.length > 0) {
// Inicializamos el estado de 'editedAgrupaciones' una sola vez.
// Usamos una función en setState para asegurarnos de que solo se ejecute
// si el estado está vacío, evitando sobreescribir ediciones del usuario.
setEditedAgrupaciones(prev => {
if (Object.keys(prev).length === 0) {
return Object.fromEntries(agrupaciones.map(a => [a.id, {}]));
}
return prev;
});
}
// Hacemos lo mismo para los logos
if (logos && logos.length > 0) {
setEditedLogos(prev => {
if (prev.length === 0) {
// Creamos una copia profunda para evitar mutaciones accidentales
return JSON.parse(JSON.stringify(logos));
}
return prev;
});
}
// La dependencia ahora es el estado de carga. El hook se ejecutará cuando
// isLoadingAgrupaciones o isLoadingLogos cambien de true a false.
}, [agrupaciones, logos]);
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => {
setEditedAgrupaciones(prev => ({
...prev,
[id]: { ...prev[id], [field]: value }
}));
const handleCreationSuccess = () => {
// Invalida la query de agrupaciones para forzar una actualización
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
};
const handleLogoChange = (agrupacionId: string, categoriaId: number, value: string) => {
setEditedLogos(prev => {
const newLogos = [...prev];
const existing = newLogos.find(l =>
l.agrupacionPoliticaId === agrupacionId &&
l.categoriaId === categoriaId &&
l.ambitoGeograficoId == null
useEffect(() => {
if (agrupaciones.length > 0) {
const initialEdits = Object.fromEntries(
agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }])
);
setEditedAgrupaciones(initialEdits);
}
}, [agrupaciones]);
if (existing) {
existing.logoUrl = value;
} else {
newLogos.push({
id: 0,
agrupacionPoliticaId: agrupacionId,
categoriaId,
logoUrl: value,
ambitoGeograficoId: null
});
}
return newLogos;
});
useEffect(() => {
if (logos) {
const logoMap = Object.fromEntries(
logos
// --- CORRECCIÓN CLAVE 1: Comprobar contra `null` en lugar de `0` ---
.filter(l => l.categoriaId === 0 && l.ambitoGeograficoId === null)
.map(l => [`${l.agrupacionPoliticaId}-${l.eleccionId}`, l.logoUrl])
);
setEditedLogos(logoMap);
}
}, [logos]);
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => {
setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }));
};
const handleLogoInputChange = (agrupacionId: string, value: string | null) => {
const key = `${agrupacionId}-${selectedEleccion.value}`;
setEditedLogos(prev => ({ ...prev, [key]: value }));
};
const handleSaveAll = async () => {
try {
const agrupacionPromises = Object.entries(editedAgrupaciones).map(([id, changes]) => {
if (Object.keys(changes).length > 0) {
const original = agrupaciones.find(a => a.id === id);
if (original) { // Chequeo de seguridad
return updateAgrupacion(id, { ...original, ...changes });
}
}
return Promise.resolve();
const agrupacionPromises = agrupaciones.map(agrupacion => {
const changes = editedAgrupaciones[agrupacion.id] || {};
const payload: UpdateAgrupacionData = {
nombreCorto: changes.nombreCorto ?? agrupacion.nombreCorto,
color: changes.color ?? agrupacion.color,
};
return updateAgrupacion(agrupacion.id, payload);
});
const logoPromise = updateLogos(editedLogos);
// --- CORRECCIÓN CLAVE 2: Enviar `null` a la API en lugar de `0` ---
const logosPayload = Object.entries(editedLogos)
.map(([key, logoUrl]) => {
const [agrupacionPoliticaId, eleccionIdStr] = key.split('-');
return { id: 0, eleccionId: parseInt(eleccionIdStr), agrupacionPoliticaId, categoriaId: 0, logoUrl: logoUrl || null, ambitoGeograficoId: null };
});
const logoPromise = updateLogos(logosPayload);
await Promise.all([...agrupacionPromises, logoPromise]);
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
queryClient.invalidateQueries({ queryKey: ['logos'] });
await queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
await queryClient.invalidateQueries({ queryKey: ['allLogos'] });
alert('¡Todos los cambios han sido guardados!');
} catch (err) {
console.error("Error al guardar todo:", err);
alert("Ocurrió un error al guardar los cambios.");
}
} catch (err) { console.error("Error al guardar todo:", err); alert("Ocurrió un error."); }
};
const getLogoValue = (agrupacionId: string): string => {
const key = `${agrupacionId}-${selectedEleccion.value}`;
return editedLogos[key] ?? '';
};
const isLoading = isLoadingAgrupaciones || isLoadingLogos;
const getLogoUrl = (agrupacionId: string, categoriaId: number) => {
return editedLogos.find(l =>
l.agrupacionPoliticaId === agrupacionId &&
l.categoriaId === categoriaId &&
l.ambitoGeograficoId == null
)?.logoUrl || '';
};
return (
<div className="admin-module">
<h3>Gestión de Agrupaciones y Logos</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3>Gestión de Agrupaciones y Logos</h3>
<div style={{ width: '350px', zIndex: 100 }}>
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => setSelectedEleccion(opt!)} />
</div>
</div>
{isLoading ? <p>Cargando...</p> : (
<>
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Nombre Corto</th>
<th>Color</th>
<th>Logo Senadores</th>
<th>Logo Diputados</th>
<th>Logo Concejales</th>
</tr>
</thead>
<tbody>
{agrupaciones.map(agrupacion => (
<tr key={agrupacion.id}>
<td>{agrupacion.nombre}</td>
<td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? agrupacion.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td>
<td>
<input
type="color"
// Usamos la función sanitizeColor para asegurarnos de que el valor sea siempre válido
value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color)}
onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)}
/>
</td>
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, SENADORES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, SENADORES_ID, e.target.value)} /></td>
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, DIPUTADOS_ID)} onChange={(e) => handleLogoChange(agrupacion.id, DIPUTADOS_ID, e.target.value)} /></td>
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, CONCEJALES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, CONCEJALES_ID, e.target.value)} /></td>
<div className="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Nombre Corto</th>
<th>Color</th>
<th>Logo</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{agrupaciones.map(agrupacion => (
<tr key={agrupacion.id}>
<td>({agrupacion.id}) {agrupacion.nombre}</td>
<td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td>
<td><input type="color" value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color)} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} /></td>
<td>
<input
type="text"
placeholder="URL..."
value={getLogoValue(agrupacion.id)}
onChange={(e) => handleLogoInputChange(agrupacion.id, e.target.value)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={handleSaveAll} style={{ marginTop: '1rem' }}>
Guardar Todos los Cambios
</button>
<AddAgrupacionForm onSuccess={handleCreationSuccess} />
</>
)}
</div>

View File

@@ -0,0 +1,117 @@
// src/components/BancasNacionalesManager.tsx
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService';
import type { Bancada, AgrupacionPolitica } from '../types';
import { OcupantesModal } from './OcupantesModal';
import './AgrupacionesManager.css';
const ELECCION_ID_NACIONAL = 2;
const camaras = ['diputados', 'senadores'] as const;
export const BancasNacionalesManager = () => {
const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados');
const [modalVisible, setModalVisible] = useState(false);
const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null);
const queryClient = useQueryClient();
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'],
queryFn: getAgrupaciones
});
const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({
queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL],
queryFn: () => getBancadas(activeTab, ELECCION_ID_NACIONAL),
});
const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => {
const bancadaActual = bancadas.find(b => b.id === bancadaId);
if (!bancadaActual) return;
const payload: UpdateBancadaData = {
agrupacionPoliticaId: nuevaAgrupacionId,
nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null,
fotoUrl: nuevaAgrupacionId ? (bancadaActual.ocupante?.fotoUrl ?? null) : null,
periodo: nuevaAgrupacionId ? (bancadaActual.ocupante?.periodo ?? null) : null,
};
try {
await updateBancada(bancadaId, payload);
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL] });
} catch (err) {
alert("Error al guardar el cambio de agrupación.");
}
};
const handleOpenModal = (bancada: Bancada) => {
setBancadaSeleccionada(bancada);
setModalVisible(true);
};
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas nacionales.</p>;
return (
<div className="admin-module">
<h3>Gestión de Bancas (Nacionales)</h3>
<p>Asigne partidos y ocupantes a las bancas del Congreso de la Nación.</p>
<div className="chamber-tabs">
{camaras.map(camara => (
<button
key={camara}
className={activeTab === camara ? 'active' : ''}
onClick={() => setActiveTab(camara)}
>
{camara === 'diputados' ? 'Diputados Nacionales (257)' : 'Senadores Nacionales (72)'}
</button>
))}
</div>
{isLoading ? <p>Cargando bancas...</p> : (
<div className="table-container">
<table>
<thead>
<tr>
<th style={{ width: '15%' }}>Banca #</th>
<th style={{ width: '35%' }}>Partido Asignado</th>
<th style={{ width: '30%' }}>Ocupante Actual</th>
<th style={{ width: '20%' }}>Acciones</th>
</tr>
</thead>
<tbody>
{bancadas.map((bancada) => (
<tr key={bancada.id}>
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
<td>
<select
value={bancada.agrupacionPoliticaId || ''}
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
>
<option value="">-- Vacante --</option>
{agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)}
</select>
</td>
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
<td>
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
Editar Ocupante
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{modalVisible && bancadaSeleccionada && (
<OcupantesModal
bancada={bancadaSeleccionada}
onClose={() => setModalVisible(false)}
activeTab={activeTab}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,125 @@
// src/components/BancasPreviasManager.tsx
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getBancasPrevias, updateBancasPrevias, getAgrupaciones } from '../services/apiService';
import type { BancaPrevia, AgrupacionPolitica } from '../types';
import { TipoCamara } from '../types';
const ELECCION_ID_NACIONAL = 2;
export const BancasPreviasManager = () => {
const queryClient = useQueryClient();
const [editedBancas, setEditedBancas] = useState<Record<string, Partial<BancaPrevia>>>({});
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'],
queryFn: getAgrupaciones,
});
const { data: bancasPrevias = [], isLoading: isLoadingBancas } = useQuery<BancaPrevia[]>({
queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL],
queryFn: () => getBancasPrevias(ELECCION_ID_NACIONAL),
});
useEffect(() => {
if (agrupaciones.length > 0) {
const initialData: Record<string, Partial<BancaPrevia>> = {};
agrupaciones.forEach(agrupacion => {
// Para Diputados
const keyDip = `${agrupacion.id}-${TipoCamara.Diputados}`;
const existingDip = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Diputados);
initialData[keyDip] = { cantidad: existingDip?.cantidad || 0 };
// Para Senadores
const keySen = `${agrupacion.id}-${TipoCamara.Senadores}`;
const existingSen = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Senadores);
initialData[keySen] = { cantidad: existingSen?.cantidad || 0 };
});
setEditedBancas(initialData);
}
}, [agrupaciones, bancasPrevias]);
const handleInputChange = (agrupacionId: string, camara: typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, value: string) => {
const key = `${agrupacionId}-${camara}`;
const cantidad = parseInt(value, 10);
setEditedBancas(prev => ({
...prev,
[key]: { ...prev[key], cantidad: isNaN(cantidad) ? 0 : cantidad }
}));
};
const handleSave = async () => {
const payload: BancaPrevia[] = Object.entries(editedBancas)
.map(([key, value]) => {
const [agrupacionPoliticaId, camara] = key.split('-');
return {
id: 0,
eleccionId: ELECCION_ID_NACIONAL,
agrupacionPoliticaId,
camara: parseInt(camara) as typeof TipoCamara.Diputados | typeof TipoCamara.Senadores,
cantidad: value.cantidad || 0,
};
})
.filter(b => b.cantidad > 0);
try {
await updateBancasPrevias(ELECCION_ID_NACIONAL, payload);
queryClient.invalidateQueries({ queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL] });
alert('Bancas previas guardadas con éxito.');
} catch (error) {
console.error(error);
alert('Error al guardar las bancas previas.');
}
};
const totalDiputados = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Diputados}`) ? sum + (value.cantidad || 0) : sum, 0);
const totalSenadores = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Senadores}`) ? sum + (value.cantidad || 0) : sum, 0);
const isLoading = isLoadingAgrupaciones || isLoadingBancas;
return (
<div className="admin-module">
<h3>Gestión de Bancas Previas (Composición Nacional)</h3>
<p>Define cuántas bancas retiene cada partido antes de la elección. Estos son los escaños que **no** están en juego.</p>
{isLoading ? <p>Cargando...</p> : (
<>
<div className="table-container">
<table>
<thead>
<tr>
<th>Agrupación Política</th>
<th>Bancas Previas Diputados (Total: {totalDiputados} / 130)</th>
<th>Bancas Previas Senadores (Total: {totalSenadores} / 48)</th>
</tr>
</thead>
<tbody>
{agrupaciones.map(agrupacion => (
<tr key={agrupacion.id}>
<td>({agrupacion.id}) {agrupacion.nombre}</td>
<td>
<input
type="number"
min="0"
value={editedBancas[`${agrupacion.id}-${TipoCamara.Diputados}`]?.cantidad || 0}
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Diputados, e.target.value)}
/>
</td>
<td>
<input
type="number"
min="0"
value={editedBancas[`${agrupacion.id}-${TipoCamara.Senadores}`]?.cantidad || 0}
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Senadores, e.target.value)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={handleSave} style={{ marginTop: '1rem' }}>Guardar Bancas Previas</button>
</>
)}
</div>
);
};

View File

@@ -1,4 +1,4 @@
// src/components/BancasManager.tsx
// src/components/BancasProvincialesManager.tsx
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService';
@@ -6,9 +6,10 @@ import type { Bancada, AgrupacionPolitica } from '../types';
import { OcupantesModal } from './OcupantesModal';
import './AgrupacionesManager.css';
const ELECCION_ID_PROVINCIAL = 1;
const camaras = ['diputados', 'senadores'] as const;
export const BancasManager = () => {
export const BancasProvincialesManager = () => {
const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados');
const [modalVisible, setModalVisible] = useState(false);
const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null);
@@ -19,16 +20,18 @@ export const BancasManager = () => {
queryFn: getAgrupaciones
});
// --- CORRECCIÓN CLAVE ---
// 1. La queryKey ahora incluye el eleccionId para ser única.
// 2. La función queryFn ahora pasa el ELECCION_ID_PROVINCIAL a getBancadas.
const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({
queryKey: ['bancadas', activeTab],
queryFn: () => getBancadas(activeTab),
queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL],
queryFn: () => getBancadas(activeTab, ELECCION_ID_PROVINCIAL),
});
const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => {
const bancadaActual = bancadas.find(b => b.id === bancadaId);
if (!bancadaActual) return;
// Si se desasigna el partido (vacante), también se limpia el ocupante
const payload: UpdateBancadaData = {
agrupacionPoliticaId: nuevaAgrupacionId,
nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null,
@@ -38,7 +41,7 @@ export const BancasManager = () => {
try {
await updateBancada(bancadaId, payload);
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab] });
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL] });
} catch (err) {
alert("Error al guardar el cambio de agrupación.");
}
@@ -49,12 +52,12 @@ export const BancasManager = () => {
setModalVisible(true);
};
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas.</p>;
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas provinciales.</p>;
return (
<div className="admin-module">
<h2>Gestión de Ocupación de Bancas</h2>
<p>Asigne a cada banca física un partido político y, opcionalmente, los datos de la persona que la ocupa.</p>
<h3>Gestión de Bancas (Provinciales)</h3>
<p>Asigne partidos y ocupantes a las bancas de la legislatura provincial.</p>
<div className="chamber-tabs">
{camaras.map(camara => (
@@ -63,7 +66,7 @@ export const BancasManager = () => {
className={activeTab === camara ? 'active' : ''}
onClick={() => setActiveTab(camara)}
>
{camara === 'diputados' ? 'Cámara de Diputados' : 'Cámara de Senadores'}
{camara === 'diputados' ? 'Diputados Provinciales (92)' : 'Senadores Provinciales (46)'}
</button>
))}
</div>
@@ -81,32 +84,19 @@ export const BancasManager = () => {
<tbody>
{bancadas.map((bancada) => (
<tr key={bancada.id}>
{/* Usamos el NumeroBanca para la etiqueta visual */}
<td>
{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}
{((activeTab === 'diputados' && bancada.numeroBanca === 92) ||
(activeTab === 'senadores' && bancada.numeroBanca === 46)) && (
<span style={{ marginLeft: '8px', fontSize: '0.8em', color: '#666', fontStyle: 'italic' }}>
(Presidencia)
</span>
)}
</td>
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
<td>
<select
value={bancada.agrupacionPoliticaId || ''}
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
>
<option value="">-- Vacante --</option>
{agrupaciones.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
{agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)}
</select>
</td>
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
<td>
<button
// El botón se habilita solo si hay un partido asignado a la banca
disabled={!bancada.agrupacionPoliticaId}
onClick={() => handleOpenModal(bancada)}
>
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
Editar Ocupante
</button>
</td>

View File

@@ -1,71 +1,81 @@
// src/components/CandidatoOverridesManager.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Select from 'react-select';
import { getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService';
import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride } from '../types';
import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService';
import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types';
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
const CATEGORIAS_OPTIONS = [
{ value: 5, label: 'Senadores' },
{ value: 6, label: 'Diputados' },
{ value: 7, label: 'Concejales' }
const ELECCION_OPTIONS = [
{ value: 0, label: 'General (Todas las elecciones)' },
{ value: 2, label: 'Elecciones Nacionales' },
{ value: 1, label: 'Elecciones Provinciales' }
];
const AMBITO_LEVEL_OPTIONS = [
{ value: 'general', label: 'General (Toda la elección)' },
{ value: 'provincia', label: 'Por Provincia' },
{ value: 'municipio', label: 'Por Municipio' }
];
export const CandidatoOverridesManager = () => {
const queryClient = useQueryClient();
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ queryKey: ['candidatos'], queryFn: getCandidatos });
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null);
const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null);
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null);
const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
const [nombreCandidato, setNombreCandidato] = useState('');
const municipioOptions = useMemo(() =>
// Añadimos la opción "General" que representará un ámbito nulo
[{ value: 'general', label: 'General (Todos los Municipios)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))]
, [municipios]);
const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin });
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]);
const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({
queryKey: ['allCandidatos'],
queryFn: () => Promise.all([getCandidatos(0), getCandidatos(1), getCandidatos(2)]).then(res => res.flat()),
});
const categoriaOptions = useMemo(() => {
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
}, [selectedEleccion]);
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return null;
};
const currentCandidato = useMemo(() => {
if (!selectedAgrupacion || !selectedCategoria) return '';
// Determina si estamos buscando un override general (null) o específico (ID numérico)
const ambitoIdBuscado = selectedMunicipio?.value === 'general' ? null : (selectedMunicipio ? parseInt(selectedMunicipio.value) : undefined);
// Si no se ha seleccionado un municipio, no buscamos nada
if (ambitoIdBuscado === undefined) return '';
const ambitoId = getAmbitoId();
return candidatos.find(c =>
c.ambitoGeograficoId === ambitoIdBuscado &&
c.agrupacionPoliticaId === selectedAgrupacion.value &&
c.eleccionId === selectedEleccion.value &&
c.ambitoGeograficoId === ambitoId &&
c.agrupacionPoliticaId === selectedAgrupacion.id &&
c.categoriaId === selectedCategoria.value
)?.nombreCandidato || '';
}, [candidatos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
}, [candidatos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setNombreCandidato(currentCandidato) }, [currentCandidato]);
useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]);
const handleSave = async () => {
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
const ambitoIdParaEnviar = selectedMunicipio.value === 'general'
? null
: parseInt(selectedMunicipio.value);
if (!selectedAgrupacion || !selectedCategoria) return;
const newCandidatoEntry: CandidatoOverride = {
id: 0, // El backend no lo necesita para el upsert
agrupacionPoliticaId: selectedAgrupacion.value,
id: 0,
eleccionId: selectedEleccion.value,
agrupacionPoliticaId: selectedAgrupacion.id,
categoriaId: selectedCategoria.value,
ambitoGeograficoId: ambitoIdParaEnviar,
nombreCandidato: nombreCandidato || null
ambitoGeograficoId: getAmbitoId(),
nombreCandidato: nombreCandidato.trim() || null
};
try {
await updateCandidatos([newCandidatoEntry]);
queryClient.invalidateQueries({ queryKey: ['candidatos'] });
queryClient.invalidateQueries({ queryKey: ['allCandidatos'] });
alert('Override de candidato guardado.');
} catch (error) {
console.error(error);
@@ -76,21 +86,30 @@ export const CandidatoOverridesManager = () => {
return (
<div className="admin-module">
<h3>Overrides de Nombres de Candidatos</h3>
<p>Configure un nombre de candidato específico para un partido, categoría y municipio (o general).</p>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<p>Configure un nombre de candidato específico para un partido en un contexto determinado.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}>
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} />
<Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." />
<Select
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
getOptionValue={opt => opt.id}
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
value={selectedAgrupacion}
onChange={setSelectedAgrupacion}
placeholder="Seleccione Agrupación..."
/>
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
) : <div />}
{selectedAmbitoLevel.value === 'municipio' ? (
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
) : <div />}
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
<div style={{ flex: 1 }}>
<label>Categoría</label>
<Select options={CATEGORIAS_OPTIONS} value={selectedCategoria} onChange={setSelectedCategoria} isClearable placeholder="Seleccione..."/>
</div>
<div style={{ flex: 1 }}>
<label>Municipio (Opcional)</label>
<Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="General..."/>
</div>
<div style={{ flex: 1 }}>
<label>Agrupación</label>
<Select options={agrupacionOptions} value={selectedAgrupacion} onChange={setSelectedAgrupacion} isClearable placeholder="Seleccione..."/>
</div>
<div style={{ flex: 2 }}>
<label>Nombre del Candidato</label>
<input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} />
</div>

View File

@@ -0,0 +1,101 @@
// src/components/ConfiguracionNacional.tsx
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { getAgrupaciones, getConfiguracion, updateConfiguracion } from '../services/apiService';
import type { AgrupacionPolitica } from '../types';
import './AgrupacionesManager.css';
export const ConfiguracionNacional = () => {
const queryClient = useQueryClient();
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const [presidenciaDiputadosId, setPresidenciaDiputadosId] = useState<string>('');
const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>('');
const [modoOficialActivo, setModoOficialActivo] = useState(false);
const [diputadosTipoBanca, setDiputadosTipoBanca] = useState<'ganada' | 'previa'>('ganada');
// El estado para el tipo de banca del senado ya no es necesario para la UI,
// pero lo mantenemos para no romper el handleSave.
const [senadoTipoBanca, setSenadoTipoBanca] = useState<'ganada' | 'previa'>('ganada');
useEffect(() => {
const loadInitialData = async () => {
try {
setLoading(true);
const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]);
setAgrupaciones(agrupacionesData);
setPresidenciaDiputadosId(configData.PresidenciaDiputadosNacional || '');
setPresidenciaSenadoId(configData.PresidenciaSenadoNacional || '');
setModoOficialActivo(configData.UsarDatosOficialesNacionales === 'true');
setDiputadosTipoBanca(configData.PresidenciaDiputadosNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
setSenadoTipoBanca(configData.PresidenciaSenadoNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
} catch (err) { console.error("Error al cargar datos de configuración nacional:", err); }
finally { setLoading(false); }
};
loadInitialData();
}, []);
const handleSave = async () => {
try {
await updateConfiguracion({
"PresidenciaDiputadosNacional": presidenciaDiputadosId,
"PresidenciaSenadoNacional": presidenciaSenadoId,
"UsarDatosOficialesNacionales": modoOficialActivo.toString(),
"PresidenciaDiputadosNacional_TipoBanca": diputadosTipoBanca,
// Aunque no se muestre, guardamos el valor para consistencia
"PresidenciaSenadoNacional_TipoBanca": senadoTipoBanca,
});
queryClient.invalidateQueries({ queryKey: ['composicionNacional'] });
alert('Configuración nacional guardada.');
} catch { alert('Error al guardar.'); }
};
if (loading) return <div className="admin-module"><p>Cargando...</p></div>;
return (
<div className="admin-module">
<h3>Configuración de Widgets Nacionales</h3>
<div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}>
{/* Columna Diputados */}
<div style={{ flex: 1, borderRight: '1px solid #ccc', paddingRight: '1rem' }}>
<label htmlFor="presidencia-diputados-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
Presidencia Cámara de Diputados
</label>
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
Este escaño es parte de los 257 diputados y se descuenta del total del partido.
</p>
<select id="presidencia-diputados-nacional" value={presidenciaDiputadosId} onChange={e => setPresidenciaDiputadosId(e.target.value)} style={{ width: '100%', padding: '8px', marginBottom: '0.5rem' }}>
<option value="">-- No Asignado --</option>
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))}
</select>
{presidenciaDiputadosId && (
<div>
<label><input type="radio" value="ganada" checked={diputadosTipoBanca === 'ganada'} onChange={() => setDiputadosTipoBanca('ganada')} /> Descontar de Banca Ganada</label>
<label style={{ marginLeft: '1rem' }}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label>
</div>
)}
</div>
{/* Columna Senadores */}
<div style={{ flex: 1 }}>
<label htmlFor="presidencia-senado-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
Presidencia Senado (Vicepresidente)
</label>
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
Este escaño es adicional a los 72 senadores y no se descuenta del total del partido.
</p>
<select id="presidencia-senado-nacional" value={presidenciaSenadoId} onChange={e => setPresidenciaSenadoId(e.target.value)} style={{ width: '100%', padding: '8px' }}>
<option value="">-- No Asignado --</option>
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))}
</select>
</div>
</div>
<button onClick={handleSave} style={{ marginTop: '1.5rem' }}>
Guardar Configuración
</button>
</div>
);
};

View File

@@ -1,43 +1,89 @@
// src/components/DashboardPage.tsx
import { useAuth } from '../context/AuthContext';
import { AgrupacionesManager } from './AgrupacionesManager';
import { OrdenDiputadosManager } from './OrdenDiputadosManager';
import { OrdenSenadoresManager } from './OrdenSenadoresManager';
import { ConfiguracionGeneral } from './ConfiguracionGeneral';
import { BancasManager } from './BancasManager';
//import { OrdenDiputadosManager } from './OrdenDiputadosManager';
//import { OrdenSenadoresManager } from './OrdenSenadoresManager';
//import { ConfiguracionGeneral } from './ConfiguracionGeneral';
import { LogoOverridesManager } from './LogoOverridesManager';
import { CandidatoOverridesManager } from './CandidatoOverridesManager';
import { WorkerManager } from './WorkerManager';
import { ConfiguracionNacional } from './ConfiguracionNacional';
import { BancasPreviasManager } from './BancasPreviasManager';
import { OrdenDiputadosNacionalesManager } from './OrdenDiputadosNacionalesManager';
import { OrdenSenadoresNacionalesManager } from './OrdenSenadoresNacionalesManager';
//import { BancasProvincialesManager } from './BancasProvincialesManager';
//import { BancasNacionalesManager } from './BancasNacionalesManager';
export const DashboardPage = () => {
const { logout } = useAuth();
const sectionStyle = {
border: '1px solid #dee2e6',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '2rem',
backgroundColor: '#f8f9fa'
};
const sectionTitleStyle = {
marginTop: 0,
borderBottom: '2px solid #007bff',
paddingBottom: '0.5rem',
marginBottom: '1.5rem',
color: '#007bff'
};
return (
<div style={{ padding: '1rem 2rem' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '2px solid #eee', paddingBottom: '1rem' }}>
<header style={{ /* ... */ }}>
<h1>Panel de Administración Electoral</h1>
<button onClick={logout}>Cerrar Sesión</button>
</header>
<main style={{ marginTop: '2rem' }}>
<AgrupacionesManager />
<div style={{ flex: '1 1 800px' }}>
<LogoOverridesManager />
</div>
<div style={{ flex: '1 1 800px' }}>
<CandidatoOverridesManager />
</div>
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
<div style={{ flex: '1 1 400px' }}>
<OrdenDiputadosManager />
</div>
<div style={{ flex: '1 1 400px' }}>
<OrdenSenadoresManager />
</div>
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>Configuración Global</h2>
<AgrupacionesManager />
<LogoOverridesManager />
<CandidatoOverridesManager />
</div>
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>Gestión de Elecciones Nacionales</h2>
<ConfiguracionNacional />
<BancasPreviasManager />
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
<div style={{ flex: '1 1 400px' }}>
<OrdenDiputadosNacionalesManager />
</div>
<div style={{ flex: '1 1 400px' }}>
<OrdenSenadoresNacionalesManager />
</div>
</div>
{/* <BancasNacionalesManager /> */}
</div>
{/*
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>Gestión de Elecciones Provinciales</h2>
<ConfiguracionGeneral />
<BancasProvincialesManager />
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
<div style={{ flex: '1 1 400px' }}>
<OrdenDiputadosManager />
</div>
<div style={{ flex: '1 1 400px' }}>
<OrdenSenadoresManager />
</div>
</div>
</div>*/}
<div style={sectionStyle}>
<h2 style={sectionTitleStyle}>Gestión de Workers y Sistema</h2>
<WorkerManager />
</div>
<ConfiguracionGeneral />
<BancasManager />
<hr style={{ margin: '2rem 0' }}/>
<WorkerManager />
</main>
</div>
);

View File

@@ -0,0 +1,72 @@
/* src/components/FormStyles.css */
.add-entity-form-container {
border-top: 2px solid #007bff;
padding-top: 1.5rem;
margin-top: 2rem;
}
.add-entity-form-container h4 {
margin-top: 0;
margin-bottom: 1rem;
color: #333;
}
.add-entity-form {
display: grid;
/* Usamos grid para un control preciso de las columnas */
grid-template-columns: 3fr 2fr 0.5fr auto;
gap: 1rem;
align-items: flex-end; /* Alinea los elementos en la parte inferior de la celda */
}
.form-field {
display: flex;
flex-direction: column;
margin-right: 15px;
}
.form-field label {
font-size: 0.85rem;
font-weight: 500;
margin-bottom: 0.25rem;
color: #555;
text-align: left;
}
.form-field input[type="text"] {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
width: 100%;
}
.form-field input[type="color"] {
height: 38px; /* Misma altura que los inputs de texto */
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px; /* Padding interno para el color */
}
.add-entity-form button {
padding: 8px 16px;
height: 38px; /* Misma altura que los inputs */
border: none;
background-color: #28a745; /* Un color verde para la acción de "crear" */
color: white;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.add-entity-form button:hover:not(:disabled) {
background-color: #218838;
}
.add-entity-form button:disabled {
background-color: #6c757d;
cursor: not-allowed;
}

View File

@@ -2,83 +2,119 @@
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Select from 'react-select';
import { getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService';
import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria } from '../types';
import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService';
import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria, ProvinciaSimple } from '../types';
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
// --- AÑADIMOS LAS CATEGORÍAS PARA EL SELECTOR ---
const CATEGORIAS_OPTIONS = [
{ value: 5, label: 'Senadores' },
{ value: 6, label: 'Diputados' },
{ value: 7, label: 'Concejales' }
const ELECCION_OPTIONS = [
{ value: 0, label: 'General (Todas las elecciones)' },
{ value: 2, label: 'Elecciones Nacionales' },
{ value: 1, label: 'Elecciones Provinciales' }
];
const AMBITO_LEVEL_OPTIONS = [
{ value: 'general', label: 'General (Toda la elección)' },
{ value: 'provincia', label: 'Por Provincia' },
{ value: 'municipio', label: 'Por Municipio' }
];
export const LogoOverridesManager = () => {
const queryClient = useQueryClient();
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({ queryKey: ['logos'], queryFn: getLogos });
// --- NUEVO ESTADO PARA LA CATEGORÍA ---
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null);
const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null);
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null);
const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
const [logoUrl, setLogoUrl] = useState('');
const municipioOptions = useMemo(() =>
[{ value: 'general', label: 'General (Todas las secciones)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))]
, [municipios]);
const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]);
const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin });
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({
queryKey: ['allLogos'],
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
});
const categoriaOptions = useMemo(() => {
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
}, [selectedEleccion]);
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return null;
};
const currentLogo = useMemo(() => {
// La búsqueda ahora depende de los 3 selectores
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return '';
if (!selectedAgrupacion || !selectedCategoria) return '';
const ambitoId = getAmbitoId();
return logos.find(l =>
l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&
l.agrupacionPoliticaId === selectedAgrupacion.value &&
l.eleccionId === selectedEleccion.value &&
l.ambitoGeograficoId === ambitoId &&
l.agrupacionPoliticaId === selectedAgrupacion.id &&
l.categoriaId === selectedCategoria.value
)?.logoUrl || '';
}, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
}, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]);
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
const handleSave = async () => {
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
if (!selectedAgrupacion || !selectedCategoria) {
alert("Por favor, seleccione una agrupación y una categoría.");
return;
}
const newLogoEntry: LogoAgrupacionCategoria = {
id: 0,
agrupacionPoliticaId: selectedAgrupacion.value,
eleccionId: selectedEleccion.value,
agrupacionPoliticaId: selectedAgrupacion.id,
categoriaId: selectedCategoria.value,
ambitoGeograficoId: parseInt(selectedMunicipio.value),
logoUrl: logoUrl || null
ambitoGeograficoId: getAmbitoId(),
logoUrl: logoUrl.trim() || null
};
try {
await updateLogos([newLogoEntry]);
queryClient.invalidateQueries({ queryKey: ['logos'] });
queryClient.invalidateQueries({ queryKey: ['allLogos'] });
alert('Override de logo guardado.');
} catch { alert('Error al guardar.'); }
};
return (
<div className="admin-module">
<h3>Overrides de Logos por Municipio y Categoría</h3>
<p>Configure una imagen específica para un partido en un municipio y categoría determinados.</p>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end' }}>
<h3>Overrides de Logos</h3>
<p>Configure una imagen específica para un partido en un contexto determinado.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}>
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} />
<Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." />
<Select
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
getOptionValue={opt => opt.id}
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
value={selectedAgrupacion}
onChange={setSelectedAgrupacion}
placeholder="Seleccione Agrupación..."
/>
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
) : <div />}
{selectedAmbitoLevel.value === 'municipio' ? (
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
) : <div />}
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
<div style={{ flex: 1 }}>
<label>Categoría</label>
<Select options={CATEGORIAS_OPTIONS} value={selectedCategoria} onChange={setSelectedCategoria} isClearable placeholder="Seleccione..."/>
</div>
<div style={{ flex: 1 }}>
<label>Municipio</label>
<Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="Seleccione..."/>
</div>
<div style={{ flex: 1 }}>
<label>Agrupación</label>
<Select options={agrupacionOptions} value={selectedAgrupacion} onChange={setSelectedAgrupacion} isClearable placeholder="Seleccione..."/>
</div>
<div style={{ flex: 2 }}>
<label>URL del Logo Específico</label>
<input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria} />
<input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} />
</div>
<button onClick={handleSave} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria}>Guardar</button>
<button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button>
</div>
</div>
);

View File

@@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos
const updateOrdenDiputadosApi = async (ids: string[]) => {
const token = localStorage.getItem('admin-jwt-token');
const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-diputados', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(ids)
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(ids)
});
if (!response.ok) {
throw new Error("Failed to save Diputados order");
@@ -38,77 +38,77 @@ const updateOrdenDiputadosApi = async (ids: string[]) => {
};
export const OrdenDiputadosManager = () => {
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => {
const fetchAndSortAgrupaciones = async () => {
setLoading(true);
try {
const data = await getAgrupaciones();
// Ordenar por el orden de Diputados. Los nulos van al final.
data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999));
setAgrupaciones(data);
} catch (error) {
console.error("Failed to fetch agrupaciones for Diputados:", error);
} finally {
setLoading(false);
}
};
fetchAndSortAgrupaciones();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupaciones((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
useEffect(() => {
const fetchAndSortAgrupaciones = async () => {
setLoading(true);
try {
const data = await getAgrupaciones();
// Ordenar por el orden de Diputados. Los nulos van al final.
data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999));
setAgrupaciones(data);
} catch (error) {
console.error("Failed to fetch agrupaciones for Diputados:", error);
} finally {
setLoading(false);
}
};
fetchAndSortAgrupaciones();
}, []);
const handleSaveOrder = async () => {
const idsOrdenados = agrupaciones.map(a => a.id);
try {
await updateOrdenDiputadosApi(idsOrdenados);
alert('Orden de Diputados guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Diputados.');
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupaciones((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
if (loading) return <p>Cargando orden de Diputados...</p>;
const handleSaveOrder = async () => {
const idsOrdenados = agrupaciones.map(a => a.id);
try {
await updateOrdenDiputadosApi(idsOrdenados);
alert('Orden de Diputados guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Diputados.');
}
};
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Diputados)</h3>
<p>Arrastre para reordenar.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={agrupaciones.map(a => a.id)}
strategy={horizontalListSortingStrategy}
>
<ul className="sortable-list-horizontal">
{agrupaciones.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{agrupacion.nombreCorto || agrupacion.nombre}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button>
</div>
);
if (loading) return <p>Cargando orden de Diputados...</p>;
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Diputados)</h3>
<p>Arrastre para reordenar.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={agrupaciones.map(a => a.id)}
strategy={horizontalListSortingStrategy}
>
<ul className="sortable-list-horizontal">
{agrupaciones.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button>
</div>
);
};

View File

@@ -0,0 +1,102 @@
// src/components/OrdenDiputadosNacionalesManager.tsx
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
import type { AgrupacionPolitica } from '../types';
import { SortableItem } from './SortableItem';
import './AgrupacionesManager.css';
const ELECCION_ID_NACIONAL = 2;
export const OrdenDiputadosNacionalesManager = () => {
// Estado para la lista que el usuario puede ordenar
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
// Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.)
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'],
queryFn: getAgrupaciones,
});
// Query 2: Obtener los datos de composición para saber qué partidos tienen bancas
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
});
// Este efecto se ejecuta cuando los datos de las queries estén disponibles
useEffect(() => {
// No hacemos nada hasta que ambas queries hayan cargado sus datos
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
return;
}
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de diputado
const partidosConBancasIds = new Set(
composicionData.diputados.partidos
.filter(p => p.bancasTotales > 0)
.map(p => p.id)
);
// Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
// Ordenamos la lista filtrada según el orden guardado en la BD
agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999));
// Actualizamos el estado que se renderiza y que el usuario puede ordenar
setAgrupacionesOrdenadas(agrupacionesFiltradas);
}, [todasAgrupaciones, composicionData]); // Dependencias: se re-ejecuta si los datos cambian
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupacionesOrdenadas((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleSaveOrder = async () => {
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
try {
await updateOrden('diputados-nacionales', idsOrdenados);
alert('Orden de Diputados Nacionales guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Diputados Nacionales.');
}
};
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>;
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Diputados Nacionales)</h3>
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
<ul className="sortable-list-horizontal">
{agrupacionesOrdenadas.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
</div>
);
};

View File

@@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos
const updateOrdenSenadoresApi = async (ids: string[]) => {
const token = localStorage.getItem('admin-jwt-token');
const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-senadores', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(ids)
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(ids)
});
if (!response.ok) {
throw new Error("Failed to save Senadores order");
@@ -38,77 +38,77 @@ const updateOrdenSenadoresApi = async (ids: string[]) => {
};
export const OrdenSenadoresManager = () => {
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => {
const fetchAndSortAgrupaciones = async () => {
setLoading(true);
try {
const data = await getAgrupaciones();
// Ordenar por el orden de Senadores. Los nulos van al final.
data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999));
setAgrupaciones(data);
} catch (error) {
console.error("Failed to fetch agrupaciones for Senadores:", error);
} finally {
setLoading(false);
}
};
fetchAndSortAgrupaciones();
}, []);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupaciones((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
useEffect(() => {
const fetchAndSortAgrupaciones = async () => {
setLoading(true);
try {
const data = await getAgrupaciones();
// Ordenar por el orden de Senadores. Los nulos van al final.
data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999));
setAgrupaciones(data);
} catch (error) {
console.error("Failed to fetch agrupaciones for Senadores:", error);
} finally {
setLoading(false);
}
};
fetchAndSortAgrupaciones();
}, []);
const handleSaveOrder = async () => {
const idsOrdenados = agrupaciones.map(a => a.id);
try {
await updateOrdenSenadoresApi(idsOrdenados);
alert('Orden de Senadores guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Senadores.');
}
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupaciones((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
if (loading) return <p>Cargando orden de Senadores...</p>;
const handleSaveOrder = async () => {
const idsOrdenados = agrupaciones.map(a => a.id);
try {
await updateOrdenSenadoresApi(idsOrdenados);
alert('Orden de Senadores guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Senadores.');
}
};
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Senado)</h3>
<p>Arrastre para reordenar.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={agrupaciones.map(a => a.id)}
strategy={horizontalListSortingStrategy}
>
<ul className="sortable-list-horizontal">
{agrupaciones.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{agrupacion.nombreCorto || agrupacion.nombre}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button>
</div>
);
if (loading) return <p>Cargando orden de Senadores...</p>;
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Senado)</h3>
<p>Arrastre para reordenar.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={agrupaciones.map(a => a.id)}
strategy={horizontalListSortingStrategy}
>
<ul className="sortable-list-horizontal">
{agrupaciones.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button>
</div>
);
};

View File

@@ -0,0 +1,94 @@
// src/components/OrdenSenadoresNacionalesManager.tsx
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
import type { AgrupacionPolitica } from '../types';
import { SortableItem } from './SortableItem';
import './AgrupacionesManager.css';
const ELECCION_ID_NACIONAL = 2;
export const OrdenSenadoresNacionalesManager = () => {
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'],
queryFn: getAgrupaciones,
});
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
});
useEffect(() => {
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
return;
}
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de senador
const partidosConBancasIds = new Set(
composicionData.senadores.partidos
.filter(p => p.bancasTotales > 0)
.map(p => p.id)
);
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999));
setAgrupacionesOrdenadas(agrupacionesFiltradas);
}, [todasAgrupaciones, composicionData]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setAgrupacionesOrdenadas((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleSaveOrder = async () => {
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
try {
await updateOrden('senadores-nacionales', idsOrdenados);
alert('Orden de Senadores Nacionales guardado con éxito!');
} catch (error) {
alert('Error al guardar el orden de Senadores Nacionales.');
}
};
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>;
return (
<div className="admin-module">
<h3>Ordenar Agrupaciones (Senado de la Nación)</h3>
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
<ul className="sortable-list-horizontal">
{agrupacionesOrdenadas.map(agrupacion => (
<SortableItem key={agrupacion.id} id={agrupacion.id}>
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
</div>
);
};

View File

@@ -0,0 +1,23 @@
// src/constants/categorias.ts
// Opciones para los selectores en el panel de administración
export const CATEGORIAS_ADMIN_OPTIONS = [
// Nacionales
{ value: 2, label: 'Senadores Nacionales' },
{ value: 3, label: 'Diputados Nacionales' },
// Provinciales
{ value: 5, label: 'Senadores Provinciales' },
{ value: 6, label: 'Diputados Provinciales' },
{ value: 7, label: 'Concejales' },
];
export const CATEGORIAS_NACIONALES_OPTIONS = [
{ value: 2, label: 'Senadores Nacionales' },
{ value: 3, label: 'Diputados Nacionales' },
];
export const CATEGORIAS_PROVINCIALES_OPTIONS = [
{ value: 5, label: 'Senadores Provinciales' },
{ value: 6, label: 'Diputados Provinciales' },
{ value: 7, label: 'Concejales' },
];

View File

@@ -1,11 +1,14 @@
// src/services/apiService.ts
import axios from 'axios';
import { triggerLogout } from '../context/authUtils';
import type { CandidatoOverride, AgrupacionPolitica, UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, MunicipioSimple } from '../types';
import type {
CandidatoOverride, AgrupacionPolitica,
UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria,
MunicipioSimple, BancaPrevia, ProvinciaSimple
} from '../types';
/**
* URL base para las llamadas a la API.
* Se usa para construir las URLs más específicas.
*/
const API_URL_BASE = import.meta.env.DEV
? 'http://localhost:5217/api'
@@ -21,13 +24,19 @@ export const AUTH_API_URL = `${API_URL_BASE}/auth`;
*/
export const ADMIN_API_URL = `${API_URL_BASE}/admin`;
// Cliente de API para endpoints de administración (requiere token)
const adminApiClient = axios.create({
baseURL: ADMIN_API_URL,
});
// --- INTERCEPTORES ---
// Cliente de API para endpoints públicos (no envía token)
const apiClient = axios.create({
baseURL: API_URL_BASE,
headers: { 'Content-Type': 'application/json' },
});
// Interceptor de Peticiones: Añade el token JWT a cada llamada
// --- INTERCEPTORES (Solo para el cliente de admin) ---
adminApiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('admin-jwt-token');
@@ -39,7 +48,6 @@ adminApiClient.interceptors.request.use(
(error) => Promise.reject(error)
);
// Interceptor de Respuestas: Maneja la expiración del token (error 401)
adminApiClient.interceptors.response.use(
(response) => response,
(error) => {
@@ -51,6 +59,32 @@ adminApiClient.interceptors.response.use(
}
);
// --- INTERFACES PARA COMPOSICIÓN NACIONAL (NECESARIAS PARA EL NUEVO MÉTODO) ---
export interface PartidoComposicionNacional {
id: string;
nombre: string;
nombreCorto: string | null;
color: string | null;
bancasFijos: number;
bancasGanadas: number;
bancasTotales: number;
ordenDiputadosNacionales: number | null;
ordenSenadoresNacionales: number | null;
}
export interface CamaraComposicionNacional {
camaraNombre: string;
totalBancas: number;
bancasEnJuego: number;
partidos: PartidoComposicionNacional[];
presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null;
}
export interface ComposicionNacionalData {
diputados: CamaraComposicionNacional;
senadores: CamaraComposicionNacional;
}
// --- SERVICIOS DE API ---
// 1. Autenticación
@@ -66,7 +100,7 @@ export const loginUser = async (credentials: LoginCredentials): Promise<string |
}
};
// 2. Agrupaciones Políticas
// 2. Agrupaciones
export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => {
const response = await adminApiClient.get('/agrupaciones');
return response.data;
@@ -77,14 +111,14 @@ export const updateAgrupacion = async (id: string, data: UpdateAgrupacionData):
};
// 3. Ordenamiento de Agrupaciones
export const updateOrden = async (camara: 'diputados' | 'senadores', ids: string[]): Promise<void> => {
export const updateOrden = async (camara: 'diputados' | 'senadores' | 'diputados-nacionales' | 'senadores-nacionales', ids: string[]): Promise<void> => {
await adminApiClient.put(`/agrupaciones/orden-${camara}`, ids);
};
// 4. Gestión de Bancas y Ocupantes
export const getBancadas = async (camara: 'diputados' | 'senadores'): Promise<Bancada[]> => {
const camaraId = camara === 'diputados' ? 0 : 1;
const response = await adminApiClient.get(`/bancadas/${camaraId}`);
// 4. Gestión de Bancas
export const getBancadas = async (camara: 'diputados' | 'senadores', eleccionId: number): Promise<Bancada[]> => {
const camaraId = (camara === 'diputados') ? 0 : 1;
const response = await adminApiClient.get(`/bancadas/${camaraId}?eleccionId=${eleccionId}`);
return response.data;
};
@@ -111,38 +145,64 @@ export const updateConfiguracion = async (data: Record<string, string>): Promise
await adminApiClient.put('/configuracion', data);
};
export const getLogos = async (): Promise<LogoAgrupacionCategoria[]> => {
const response = await adminApiClient.get('/logos');
// 6. Logos y Candidatos
export const getLogos = async (eleccionId: number): Promise<LogoAgrupacionCategoria[]> => {
const response = await adminApiClient.get(`/logos?eleccionId=${eleccionId}`);
return response.data;
};
export const updateLogos = async (data: LogoAgrupacionCategoria[]): Promise<void> => {
await adminApiClient.put('/logos', data);
};
export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => {
// Ahora usa adminApiClient, que apunta a /api/admin/
// La URL final será /api/admin/catalogos/municipios
const response = await adminApiClient.get('/catalogos/municipios');
export const getCandidatos = async (eleccionId: number): Promise<CandidatoOverride[]> => {
const response = await adminApiClient.get(`/candidatos?eleccionId=${eleccionId}`);
return response.data;
};
// 6. Overrides de Candidatos
export const getCandidatos = async (): Promise<CandidatoOverride[]> => {
const response = await adminApiClient.get('/candidatos');
return response.data;
};
export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => {
await adminApiClient.put('/candidatos', data);
};
// 7. Gestión de Logging
export interface UpdateLoggingLevelData {
level: string;
}
// 7. Catálogos
export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => {
const response = await adminApiClient.get('/catalogos/municipios');
return response.data;
};
// 8. Logging
export interface UpdateLoggingLevelData { level: string; }
export const updateLoggingLevel = async (data: UpdateLoggingLevelData): Promise<void> => {
// Este endpoint es específico, no es parte de la configuración general
await adminApiClient.put(`/logging-level`, data);
};
// 9. Bancas Previas
export const getBancasPrevias = async (eleccionId: number): Promise<BancaPrevia[]> => {
const response = await adminApiClient.get(`/bancas-previas/${eleccionId}`);
return response.data;
};
export const updateBancasPrevias = async (eleccionId: number, data: BancaPrevia[]): Promise<void> => {
await adminApiClient.put(`/bancas-previas/${eleccionId}`, data);
};
// 10. Obtener Composición Nacional (Endpoint Público)
export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => {
// Este es un endpoint público, por lo que usamos el cliente sin token de admin.
const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`);
return response.data;
};
// Obtenemos las provincias para el selector de ámbito
export const getProvinciasForAdmin = async (): Promise<ProvinciaSimple[]> => {
const response = await adminApiClient.get('/catalogos/provincias');
return response.data;
};
export interface CreateAgrupacionData {
nombre: string;
nombreCorto: string | null;
color: string | null;
}
// Servicio para crear una nueva agrupación
export const createAgrupacion = async (data: CreateAgrupacionData): Promise<AgrupacionPolitica> => {
const response = await adminApiClient.post('/agrupaciones', data);
return response.data;
};

View File

@@ -8,6 +8,8 @@ export interface AgrupacionPolitica {
color: string | null;
ordenDiputados: number | null;
ordenSenadores: number | null;
ordenDiputadosNacionales: number | null;
ordenSenadoresNacionales: number | null;
}
export interface UpdateAgrupacionData {
@@ -30,9 +32,9 @@ export interface OcupanteBanca {
periodo: string | null;
}
// Nueva interfaz para la Bancada
export interface Bancada {
id: number;
eleccionId: number; // Clave para diferenciar provinciales de nacionales
camara: TipoCamaraValue;
numeroBanca: number;
agrupacionPoliticaId: string | null;
@@ -40,18 +42,33 @@ export interface Bancada {
ocupante: OcupanteBanca | null;
}
// Nueva interfaz para Bancas Previas
export interface BancaPrevia {
id: number;
eleccionId: number;
camara: TipoCamaraValue;
agrupacionPoliticaId: string;
agrupacionPolitica?: AgrupacionPolitica; // Opcional para la UI
cantidad: number;
}
export interface LogoAgrupacionCategoria {
id: number;
eleccionId: number; // Clave para diferenciar
agrupacionPoliticaId: string;
categoriaId: number;
categoriaId: number | null;
logoUrl: string | null;
ambitoGeograficoId: number | null;
}
export interface MunicipioSimple { id: string; nombre: string; }
export interface ProvinciaSimple { id: string; nombre: string; }
export interface CandidatoOverride {
id: number;
eleccionId: number; // Clave para diferenciar
agrupacionPoliticaId: string;
categoriaId: number;
ambitoGeograficoId: number | null;

File diff suppressed because it is too large Load Diff

View File

@@ -18,13 +18,20 @@
"axios": "^1.11.0",
"d3-geo": "^3.1.1",
"d3-shape": "^3.2.0",
"highcharts": "^12.4.0",
"highcharts-react-official": "^3.2.2",
"react": "^19.1.1",
"react-circular-progressbar": "^2.2.0",
"react-dom": "^19.1.1",
"react-hot-toast": "^2.6.0",
"react-icons": "^5.5.0",
"react-pdf": "^10.1.0",
"react-select": "^5.10.2",
"react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support",
"react-tooltip": "^5.29.1",
"topojson-client": "^3.1.0"
"swiper": "^12.0.2",
"topojson-client": "^3.1.0",
"vite-plugin-svgr": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.33.0",

View File

@@ -4,9 +4,16 @@
// El dominio donde se alojan los widgets
const WIDGETS_HOST = 'https://elecciones2025.eldia.com';
// Función para cargar dinámicamente un script
// Estado interno para evitar recargas y re-fetch innecesarios
const __state = {
assetsLoaded: false,
manifest: null,
};
// Función para cargar dinámicamente un script (evita duplicados)
function loadScript(src) {
return new Promise((resolve, reject) => {
if ([...document.scripts].some(s => s.src === src)) return resolve();
const script = document.createElement('script');
script.type = 'module';
script.src = src;
@@ -16,73 +23,116 @@
});
}
// Función para cargar dinámicamente una hoja de estilos
// Función para cargar dinámicamente una hoja de estilos (evita duplicados)
function loadCSS(href) {
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(l => l.href === href)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// Función principal
// Carga (una sola vez) JS/CSS definidos por el manifest
async function ensureAssetsFromManifest() {
if (__state.assetsLoaded) return;
// 1) Obtener el manifest.json (cache: no-store por si hay deploys frecuentes)
if (!__state.manifest) {
const response = await fetch(`${WIDGETS_HOST}/manifest.json`, { cache: 'no-store' });
if (!response.ok) throw new Error('No se pudo cargar el manifest de los widgets.');
__state.manifest = await response.json();
}
// 2) Encontrar el entry principal (isEntry=true)
const entryKey = Object.keys(__state.manifest).find(key => __state.manifest[key].isEntry);
if (!entryKey) throw new Error('No se encontró el punto de entrada en el manifest.');
const entry = __state.manifest[entryKey];
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
// 3) Cargar el CSS si existe (una sola vez)
if (entry.css && entry.css.length > 0) {
entry.css.forEach(cssFile => loadCSS(`${WIDGETS_HOST}/${cssFile}`));
}
// 4) Cargar el JS principal (una sola vez)
await loadScript(jsUrl);
__state.assetsLoaded = true;
}
// Render: busca contenedores y llama a la API global del widget
function renderWidgetsOnPage() {
if (!(window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function')) {
// La librería aún no expuso la API (puede ocurrir en primeros ms tras cargar)
return;
}
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
if (widgetContainers.length === 0) {
// En algunas rutas no habrá widgets: no es error.
return;
}
widgetContainers.forEach(container => {
window.EleccionesWidgets.render(container, container.dataset);
});
}
// Función principal (re-usable) para inicializar y renderizar
async function initWidgets() {
try {
// 1. Obtener el manifest.json para saber los nombres de archivo actuales
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
if (!response.ok) {
throw new Error('No se pudo cargar el manifest de los widgets.');
}
const manifest = await response.json();
// 2. Encontrar el punto de entrada principal (nuestro main.tsx)
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
if (!entryKey) {
throw new Error('No se encontró el punto de entrada en el manifest.');
}
const entry = manifest[entryKey];
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
// 3. Cargar el CSS si existe
if (entry.css && entry.css.length > 0) {
entry.css.forEach(cssFile => {
const cssUrl = `${WIDGETS_HOST}/${cssFile}`;
loadCSS(cssUrl);
});
}
// 4. Cargar el JS principal y esperar a que esté listo
await loadScript(jsUrl);
// 5. Una vez cargado, llamar a la función de renderizado.
if (window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function') {
console.log('Bootstrap: La función render existe. Renderizando todos los widgets encontrados...');
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
if (widgetContainers.length === 0) {
console.warn('Bootstrap: No se encontraron contenedores de widget en la página.');
}
widgetContainers.forEach(container => {
// 'dataset' es un objeto que contiene todos los atributos data-*
window.EleccionesWidgets.render(container, container.dataset);
});
} else {
console.error('Bootstrap: ERROR CRÍTICO - La función render() NO SE ENCONTRÓ en window.EleccionesWidgets.');
console.log('Bootstrap: Contenido de window.EleccionesWidgets:', window.EleccionesWidgets);
}
await ensureAssetsFromManifest();
renderWidgetsOnPage();
} catch (error) {
console.error('Error al inicializar los widgets de elecciones:', error);
}
}
if (document.readyState === 'loading') { // Aún cargando
// Exponer para invocación manual (por ejemplo, en hooks del router)
window.__eleccionesInit = initWidgets;
// Primer render en carga inicial
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidgets);
} else { // Ya cargado
} else {
initWidgets();
}
// --- Reinvocar en cada navegación de SPA ---
function dispatchLocationChange() {
window.dispatchEvent(new Event('locationchange'));
}
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function () {
const ret = orig.apply(this, arguments);
dispatchLocationChange();
return ret;
};
});
window.addEventListener('popstate', dispatchLocationChange);
let navDebounce = null;
window.addEventListener('locationchange', () => {
clearTimeout(navDebounce);
navDebounce = setTimeout(() => {
initWidgets();
}, 0);
});
// --- (Opcional) Re-render si aparecen contenedores luego del montaje de la vista ---
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
const added = [...m.addedNodes].some(n =>
n.nodeType === 1 &&
(n.matches?.('[data-elecciones-widget]') || n.querySelector?.('[data-elecciones-widget]'))
);
if (added) { renderWidgetsOnPage(); break; }
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="1.3493201mm"
height="1.6933239mm"
viewBox="0 0 1.3493201 1.6933238"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-103.9813,-147.63758)">
<path
d="m 105.33062,148.53708 -0.0264,0.0794 -0.1323,0.13229 -0.1852,0.0265 -0.15875,0.15875 -0.21167,0.39687 -0.52917,-0.44979 -0.10583,-0.37042 0.13229,-0.58208 0.34396,-0.29104 0.13229,0.0794 0.10583,0.0529 0.10584,0.0794 0.18521,0.13229 0.0794,0.0794 0.10583,0.15875 0.0794,0.15875 z"
id="ARC"
name="Ciudad de Buenos Aires"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 821 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="22.092972mm"
height="36.143562mm"
viewBox="0 0 22.092972 36.143562"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-93.662753,-130.4396)">
<path
d="m 95.646872,134.9375 1.190625,-0.3175 0.264583,-0.15875 0.47625,-0.50271 0.582083,-0.66146 0.05292,-0.10583 0.02646,-0.13229 -0.15875,-1.45521 0.05292,-0.21167 0.370417,-0.13229 1.5875,-0.39687 1.03188,-0.29105 0.60854,-0.13229 h 0.21167 l 1.08479,0.26459 0.0529,0.0264 v 0.0265 l 0.0265,0.0794 -0.0265,0.15875 v 0.0794 l 0.0265,0.0529 0.0794,0.0529 0.39687,0.23812 0.0265,0.0265 0.0529,0.0794 0.0529,0.0529 0.0265,0.0265 0.34396,0.0529 0.0529,0.0265 0.0265,0.0265 0.0794,0.0529 0.0529,0.0529 0.0529,0.0265 h 0.0529 l 0.3175,0.0265 h 0.10583 l 0.635,-0.10583 0.0794,0.0265 0.39687,0.10583 0.0529,0.0265 0.21167,-0.0529 h 0.0529 l 0.0265,0.0265 0.0265,0.0265 v 0.0265 0.0265 0.0265 l -0.0265,0.18521 v 0.0265 l 0.0265,0.0529 h 0.0529 l 0.1852,0.0265 0.0529,0.0264 0.0529,0.0265 v 0.0265 0.0529 0.10583 l 0.0265,0.0265 0.0264,0.0529 0.1323,0.0264 h 3.12208 l 1.85208,-0.0264 0.89959,0.0529 0.26458,0.15875 0.84667,2.2225 -0.26459,1.66688 0.0265,0.15875 0.0265,0.10583 1.21708,1.34938 0.26459,0.37041 -0.0529,0.37042 -1.40229,5.26521 -0.10584,0.21166 -0.23812,0.21167 -0.21167,0.21167 -0.0794,0.0794 v 0.0794 0.0265 l 0.0794,0.15875 0.0265,0.0265 v 0.0265 0.0529 0.0794 l -0.0265,0.29105 v 0.0794 l 0.0265,0.39688 0.0265,0.0264 v 0.0265 0.0265 l 0.0794,0.13229 0.0529,0.0529 0.0265,0.0265 v 0.0529 0.0529 l -0.0794,0.18521 -0.0529,0.10583 -0.0265,0.10584 -0.0265,0.15875 v 0.52916 l 0.13229,0.15875 0.13229,0.10584 0.15875,0.21166 0.0529,0.0265 0.15875,0.0794 0.10583,0.0794 0.0265,0.0265 0.0265,0.0529 0.15875,0.55562 0.0265,0.10583 0.13229,0.21167 0.39688,0.29104 0.0529,0.0794 0.0794,0.0794 0.0265,0.0529 0.0264,0.13229 0.0529,0.21167 v 0.21166 l 0.0265,0.0265 0.0265,0.0529 0.0265,0.0265 0.0529,0.0529 0.0265,0.0265 0.0265,0.0529 v 0.0794 l -0.0265,0.0529 v 0.0265 0.0529 l -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.10583 v 0.0265 0.0529 l 0.0265,0.0265 h 0.0529 l 0.15875,0.0265 0.0265,0.0265 H 115.2 l 0.0265,0.0265 v 0.0265 l 0.15875,0.29104 0.10583,0.21167 v 0.0529 l 0.0265,0.0265 0.15875,0.10584 0.0265,0.0265 v 0.0529 l 0.0529,0.0794 v 0.0529 0.0529 0.0529 l -0.0264,0.10583 -0.0794,0.15875 -0.0265,0.13229 v 0.39688 0.15875 l -0.0265,0.13229 -0.0265,0.0265 -0.0794,0.15875 -0.18521,0.21166 -0.0794,0.0794 -0.0794,0.0265 -0.0265,0.0265 h -0.0264 l -0.0794,0.0264 h -0.0529 l -0.0794,-0.0264 -0.13229,0.21166 -1.24354,2.01084 -1.66688,2.7252 -2.2225,3.6248 -2.67229,-0.0265 -0.18521,0.23812 -0.0265,1.08479 -0.0265,2.83105 h -3.12208 -2.91041 -3.28084 v -0.0265 -6.87917 l -0.264584,-4.97417 0.02646,-0.0529 0.02646,-0.0794 0.15875,-0.29104 0.02646,-0.0265 v -0.0265 l 0.02646,-0.0264 h 0.05292 l 0.132292,-0.0265 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0529 v -0.0264 -0.0529 l -0.05292,-0.13229 v -0.10584 l 0.02646,-0.0794 0.132292,-0.29104 0.02646,-0.0794 v -0.0529 l -0.02646,-0.10584 -0.02646,-0.0794 0.02646,-0.0529 v -0.0529 l 0.211666,-0.39687 0.02646,-0.0529 v -0.0529 -0.23813 l 0.02646,-0.0529 v -0.0529 l 0.07937,-0.13229 0.02646,-0.0529 v -0.0265 -0.10583 -0.0794 -0.0265 l 0.02646,-0.0529 0.07938,-0.0794 0.02646,-0.0265 0.02646,-0.0529 0.02646,-0.0529 0.02646,-0.26458 0.132292,-0.29104 0.02646,-0.18521 0.02646,-0.21167 v -0.0529 l -0.02646,-0.0529 -0.02646,-0.13229 -0.02646,-0.10584 -0.02646,-0.13229 -0.132292,-0.23812 -0.02646,-0.0794 v -0.0529 -0.15875 l 0.02646,-0.15875 v -0.1852 -0.18521 l -0.02646,-0.0794 v -0.0794 h -0.02646 l -0.02646,-0.0265 -0.05292,-0.0265 h -0.02646 -0.02646 l -0.105833,0.0265 -0.555625,0.15875 -0.582084,0.0794 -0.07937,-0.0265 -0.02646,-0.0265 -0.02646,-0.0265 v -0.0529 -0.0529 -0.26459 l -0.02646,-0.0794 v -0.0265 l -0.07937,-0.13229 -0.05292,-0.10584 -0.05292,-0.13229 v -0.0794 -0.0794 -0.10583 -0.0265 l -0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.0529 -0.15875,-0.13229 -0.15875,-0.18521 -1.534583,-0.9525 -0.264584,-0.13229 -0.185208,-0.0529 h -0.47625 v -1.69334 l -0.02646,-2.24896 v -1.11125 l 0.05292,-0.39687 0.370417,-1.08479 0.07937,-0.21167 0.105833,-0.3175 0.449792,-1.34937 v -0.10584 l 0.05292,-0.18521 0.132291,-0.44979 0.07937,-0.23812 0.02646,-0.0794 0.105833,-0.18521 0.07937,-0.1852 z"
id="ARX"
name="Córdoba"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="26.908112mm"
height="29.739168mm"
viewBox="0 0 26.908112 29.739168"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-91.281242,-133.61455)">
<path
d="m 91.545834,150.99771 v -5.00063 l -0.05292,-3.78354 -0.15875,-0.82021 -0.02646,-0.0794 -0.02646,-0.10584 0.02646,-0.29104 0.05292,-0.44979 0.07937,-0.0794 0.105834,-0.0529 h 6.085416 0.423334 2.434162 3.01625 0.21167 4.89479 0.21167 0.0264 v -1.05833 l 0.0265,-5.66209 h 3.28084 2.91041 3.12208 v 1.82563 1.50812 l -0.0264,3.04271 v 0.23813 2.83104 1.53458 1.53458 2.9898 l 0.0264,3.09562 -0.0264,0.3175 0.0264,3.41313 v 3.88937 l -0.0264,3.51896 -0.47625,-0.0529 -0.21167,-0.0794 -0.15875,-0.15875 -0.71437,-0.50271 -0.18521,-0.0794 -0.10584,-0.13229 -0.13229,-0.23812 -0.0794,-0.0794 -0.0794,-0.0529 -0.21166,-0.1323 -0.37042,-0.0794 -0.10583,-0.0529 -0.47625,-0.58208 -0.0794,-0.0794 -0.26458,-0.0265 -0.10584,-0.0529 -0.39687,-0.26458 -1.77271,-0.68792 h -0.39687 l -1.50813,-0.29104 -0.10583,0.0529 -0.18521,-0.0529 -0.39688,0.0529 -0.13229,-0.10583 h -0.0529 -0.74084 l -0.82021,0.15875 -0.26458,-0.0265 -0.23812,0.10584 h -0.0794 l -0.0794,-0.0265 -0.21167,-0.15875 -0.47625,-0.18521 -0.29104,-0.0529 h -0.15875 l -0.10584,0.0794 -0.0794,0.0794 -0.13229,0.0794 -0.10584,0.0529 h -0.10583 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0529,-0.0529 -0.0794,-0.0265 h -0.23813 -0.0529 l -0.10583,-0.10583 -0.0529,-0.0265 -0.0794,-0.0265 -0.21167,-0.0265 -0.18521,-0.0794 -0.15875,-0.0265 -0.10583,-0.0794 h -0.0529 l -1.40229,-0.18521 -0.74083,0.13229 h -0.23813 l -0.635,-0.18521 h -0.15875 l -0.13229,-0.0529 -0.0529,-0.10584 -0.0529,-0.34396 -0.0529,-0.21166 -0.13229,-0.18521 -0.18521,-0.15875 -0.18521,-0.10583 -0.238117,-0.0529 -0.05292,-0.0265 -0.105833,-0.0794 -0.02646,-0.0265 -0.05292,-0.0265 -0.211667,-0.13229 -0.264583,-0.0265 -0.105834,-0.0529 -0.185208,-0.13229 -0.238125,-0.0529 -0.555625,-0.29104 h -0.05292 l -0.02646,-0.0529 -0.07937,-0.10584 -0.07937,-0.0794 -0.211667,-0.29104 -0.132291,-0.37042 v -0.0794 -0.15875 l -0.02646,-0.10583 -0.05292,-0.10583 -0.132292,-0.0529 h -0.105833 l -0.47625,0.0794 -0.238125,0.15875 -0.15875,0.0529 -0.396875,-0.0529 -0.15875,0.0794 -0.396875,0.0265 -0.211667,-0.10583 -0.185208,-0.18521 -0.15875,-0.21167 -0.291042,-0.635 -0.15875,-0.15875 -0.211667,-0.10583 h -0.05292 l -0.15875,0.0529 h -0.05292 l -0.07937,-0.0794 h -0.05292 l -0.05292,-0.0529 -0.05292,-0.15875 -0.02646,-0.13229 -0.02646,-0.13229 0.02646,-0.34396 0.132291,-0.23812 0.15875,-0.15875 0.370417,-0.26459 0.132292,-0.18521 0.07937,-0.26458 v -0.3175 l -0.07937,-0.21167 -0.132292,-0.23812 -0.15875,-0.21167 -0.15875,-0.13229 -0.238125,-0.13229 z"
id="ARL"
name="La Pampa"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="13.838294mm"
height="27.438555mm"
viewBox="0 0 13.838294 27.438555"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-97.895573,-134.6725)">
<path
d="m 101.54708,134.85812 0.37042,0.0794 0.13229,0.0529 0.15875,0.0794 0.0529,0.0265 h 0.0265 0.0529 0.10584 l 0.10583,-0.0529 0.0529,-0.0265 0.0265,0.0265 0.0529,0.0265 0.13229,0.0794 h 0.0265 l 0.29104,-0.0529 h 0.0529 l 0.0529,0.0265 0.0794,0.0265 0.13229,0.0794 h 0.0529 0.0529 l 0.1852,-0.0529 h 0.0794 l 0.26458,0.0265 0.26459,-0.0265 0.29104,-0.10583 0.21166,-0.13229 0.1323,-0.0529 0.26458,-0.0529 h 0.21167 l 0.26458,0.0529 h 0.0529 l 0.0264,0.0265 0.0794,0.0529 0.0529,0.0265 h 0.0264 l 0.10584,0.0265 h 0.26458 l 0.52917,-0.10583 h 0.47625 l 0.18521,0.0529 0.26458,0.13229 1.53458,0.9525 0.15875,0.18521 0.15875,0.13229 0.0265,0.0529 0.0265,0.0529 0.0265,0.10584 v 0.0264 0.10584 0.0794 0.0794 l 0.0529,0.13229 0.0529,0.10583 0.0794,0.13229 v 0.0265 l 0.0264,0.0794 v 0.26458 0.0529 0.0529 l 0.0265,0.0265 0.0265,0.0265 0.0794,0.0265 0.58208,-0.0794 0.55562,-0.15875 0.10584,-0.0265 h 0.0265 0.0264 l 0.0529,0.0265 0.0265,0.0265 h 0.0265 v 0.0794 l 0.0265,0.0794 v 0.18521 0.18521 l -0.0265,0.15875 v 0.15875 0.0529 l 0.0265,0.0794 0.13229,0.23813 0.0265,0.13229 0.0265,0.10583 0.0265,0.13229 0.0265,0.0529 v 0.0529 l -0.0265,0.21166 -0.0265,0.18521 -0.13229,0.29104 -0.0265,0.26459 -0.0265,0.0529 -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.0794 -0.0264,0.0529 v 0.0265 0.0794 0.10584 0.0264 l -0.0265,0.0529 -0.0794,0.13229 v 0.0529 l -0.0265,0.0529 v 0.23812 0.0529 l -0.0264,0.0529 -0.21167,0.39688 v 0.0529 l -0.0265,0.0529 0.0265,0.0794 0.0265,0.10583 v 0.0529 l -0.0265,0.0794 -0.13229,0.29104 -0.0265,0.0794 v 0.10583 l 0.0529,0.13229 v 0.0529 0.0265 l -0.0265,0.0529 -0.0529,0.0265 -0.0265,0.0265 -0.13229,0.0265 h -0.0529 l -0.0265,0.0265 v 0.0265 l -0.0265,0.0265 -0.15875,0.29104 -0.0265,0.0794 -0.0265,0.0529 0.26458,4.97417 v 6.87916 0.0265 l -0.0265,5.66208 v 1.05834 h -0.0265 -0.21167 -4.89479 -0.21167 -3.01625 l -0.0529,-0.0265 -0.0265,-0.21167 v -0.10583 l 0.0794,-0.3175 0.0265,-0.50271 0.23813,-1.05833 0.0529,-0.68792 0.18521,-0.37042 0.0265,-0.13229 v -0.60854 l 0.0794,-0.42333 v -0.10584 l -0.0794,-0.42333 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 v -0.0794 l -0.0265,-0.0529 -0.0265,-0.0529 -0.0529,-0.0794 0.10583,-0.39688 V 156.21 l -0.0265,-0.10583 -0.10584,-0.21167 -0.0265,-0.0794 -0.0265,-0.10583 -0.0794,-0.26458 v -0.0794 l 0.0529,-0.34396 -0.0265,-0.13229 -0.13229,-0.50271 -0.66146,-1.29645 -0.23813,-0.29105 -0.0529,-0.10583 -0.0265,-0.13229 -0.13229,-0.42333 -0.0265,-0.58209 -0.0529,-0.13229 -0.0794,-0.0529 -0.0529,-0.10583 0.0794,-0.13229 -0.0794,-0.13229 V 150.495 l -0.0529,-0.15875 v -0.0794 l 0.0794,-0.13229 0.0265,-0.10583 0.0265,-0.0265 h 0.0264 l 0.0794,0.0529 h 0.0265 l 0.0794,-0.10584 0.0265,-0.13229 0.0265,-0.34396 0.0265,-0.21166 v -0.10584 l -0.10584,-0.15875 -0.0264,-0.21166 -0.0529,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 -0.0794,-0.10583 -0.0529,-0.29104 -0.0529,-0.1323 -0.10583,-0.0794 -0.0794,-0.0794 -0.18521,-0.13229 -0.10583,-0.0794 -0.0529,-0.0794 v -0.13229 l -0.13229,-0.44979 -0.13229,-0.21167 -0.0794,-0.29104 -0.0265,-0.0794 -0.07938,-0.21167 -0.02646,-0.0794 -0.105833,-0.10583 -0.529167,-0.89958 -0.05292,-0.15875 v -0.13229 -0.29105 -0.10583 l -0.132291,-0.3175 v -0.23812 l -0.05292,-0.0529 -0.02646,-0.18521 0.02646,-0.50271 -0.02646,-0.60854 -0.05292,-0.13229 -0.02646,-0.0529 v -0.0794 l 0.02646,-0.13229 v -0.0794 l -0.105833,-0.26458 -0.05292,-0.18521 0.02646,-0.0794 0.105834,-0.0794 v -0.13229 l -0.07937,-0.29104 0.02646,-0.0529 0.02646,-0.18521 0.105834,-0.18521 -0.02646,-0.0794 -0.05292,-0.0794 -0.02646,-0.10583 -0.02646,-0.10583 -0.05292,-0.0529 -0.05292,-0.0529 -0.05292,-0.0794 -0.05292,-0.0529 -0.02646,-0.10583 -0.105833,-0.52917 -0.02646,-0.0794 v -0.10584 l -0.07937,-0.18521 -0.02646,-0.1852 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0265 v -0.10584 -0.0529 l -0.05292,-0.0794 -0.05292,-0.0529 -0.132291,-0.10584 -0.132292,-0.58208 -0.02646,-0.29104 v -0.42334 -0.21166 -0.39688 -0.10583 l -0.02646,-0.13229 0.02646,-0.0529 v -0.0794 l 0.07937,-0.29105 0.02646,-0.0265 v -0.0529 l -0.02646,-0.0794 v -0.0529 l -0.02646,-0.0265 v -0.0265 l 0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.15875 v -0.0794 -0.0265 -0.0529 l 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.132292,-0.0794 0.238125,0.0529 0.15875,0.0529 h 0.07937 l 0.132292,0.0265 0.449791,-0.0529 0.185209,-0.0529 h 0.05292 0.07937 l 0.105834,0.0265 0.07937,0.0265 0.05292,0.0265 0.05292,0.0794 h 0.02646 l 0.02646,0.0265 h 0.05292 l 0.185209,0.0529 z"
id="ARD"
name="San Luis"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="19.023661mm"
height="28.363354mm"
viewBox="0 0 19.023661 28.363354"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-95.24991,-134.40833)">
<path
d="m 99.059995,138.29771 0.02646,-0.21167 0.02646,-0.0265 0.185208,-0.39687 0.05292,-0.10584 v -0.0529 -0.21167 l 0.02646,-0.0529 0.02646,-0.10583 1.164167,-2.59292 0.10583,-0.13229 0.21167,0.0265 1.05833,0.34396 0.18521,0.0529 2.88396,0.0529 h 9.02229 l 0.15875,0.50271 0.0794,2.46063 v 0.68791 3.28084 6.27062 1.16417 l -0.3175,2.2225 -0.52917,3.4925 -0.47625,3.04271 -0.26458,1.66687 -0.47625,3.09563 -0.84667,-2.2225 -0.26458,-0.15875 -0.89959,-0.0529 -1.85208,0.0265 h -3.12208 l -0.1323,-0.0265 -0.0264,-0.0529 -0.0265,-0.0265 v -0.10583 -0.0529 -0.0264 l -0.0529,-0.0265 -0.0529,-0.0265 -0.1852,-0.0265 h -0.0529 l -0.0265,-0.0529 v -0.0264 l 0.0265,-0.18521 v -0.0265 -0.0265 -0.0265 l -0.0265,-0.0265 -0.0265,-0.0265 h -0.0529 l -0.21167,0.0529 -0.0529,-0.0265 -0.39687,-0.10583 -0.0794,-0.0265 -0.635,0.10584 h -0.10583 l -0.3175,-0.0265 h -0.0529 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0794,-0.0529 -0.0265,-0.0265 -0.0529,-0.0265 -0.34396,-0.0529 -0.0265,-0.0265 -0.0529,-0.0529 -0.0529,-0.0794 -0.0265,-0.0265 -0.39687,-0.23812 -0.0794,-0.0529 -0.0265,-0.0529 v -0.0794 l 0.0265,-0.15875 -0.0265,-0.0794 v -0.0265 l -0.0529,-0.0265 -1.08479,-0.26458 h -0.21167 l -0.608543,0.13229 -1.031875,0.29104 -1.5875,0.39687 -0.132292,-0.15875 -0.05292,-0.13229 V 158.67 l -0.05292,-0.13229 -0.370417,-0.635 -0.238125,-0.58208 -0.238125,-1.82563 0.02646,-0.52916 -0.105833,-1.21709 v -0.10583 l -0.02646,-0.0265 -0.02646,-0.0265 -0.105833,-0.10583 -0.211667,-0.13229 h -0.05292 l -0.02646,-0.0265 v -0.0265 l -0.02646,-0.0265 v -0.21166 -0.0529 l 0.02646,-0.0265 0.105834,-0.0529 0.02646,-0.0265 0.05292,-0.13229 0.07937,-0.1323 0.132291,-0.13229 0.07937,-0.10583 0.05292,-0.15875 0.05292,-0.34396 -0.05292,-0.92604 -0.47625,-2.27542 0.05292,-0.0529 0.07937,-0.0265 h 0.02646 0.05292 l 0.07937,0.0265 h 0.05292 0.07937 l 0.05292,-0.0265 0.05292,-0.0529 0.05292,-0.10583 0.15875,-0.50271 0.05292,-0.0529 0.02646,-0.0265 h 0.07937 0.02646 l -0.02646,-0.0794 -0.132291,-0.3175 -0.238125,-0.55563 v -0.13229 l 0.05292,-0.18521 h 0.05292 l 0.02646,-0.0264 h 0.105834 0.05292 0.02646 0.02646 l 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.05292,-0.10583 0.02646,-0.0529 v -0.0529 h -0.02646 l -0.105833,-0.0794 -0.105833,-0.0529 h -0.07937 l -0.105834,-0.0265 -0.07937,-0.0529 -0.02646,-0.0265 0.343958,-0.39687 0.07937,-0.0794 0.264584,-0.3175 0.07937,-0.0794 0.05292,-0.0265 0.132292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.132291,-0.37042 0.02646,-0.0265 0.105833,-0.13229 0.15875,-0.44979 0.05292,-0.26459 0.07937,-0.34395 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.02646,-0.0529 h 0.05292 l 0.02646,-0.0265 0.07937,-0.15875 0.185209,-0.76729 0.07937,-0.26458 0.02646,-0.10584 H 98.081 l 0.02646,-0.0265 h 0.07937 0.07937 0.02646 l 0.02646,-0.0529 0.02646,-0.0529 0.15875,-0.58208 0.02646,-0.0529 0.07937,-0.0265 0.343959,0.0529 0.02646,-0.0265 v -0.10583 -0.23813 l -0.02646,-0.44979 -0.105834,-0.635 v -0.0529 l 0.02646,-0.0529 0.185208,-0.66146 0.02646,-0.18521 z"
id="ARG"
name="Santiago del Estero"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="9.3935156mm"
height="12.197932mm"
viewBox="0 0 9.3935156 12.197932"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1"
transform="translate(-100.01234,-142.34548)">
<path
d="m 100.77979,143.45708 1.61396,0.26459 0.1852,0.0265 0.0529,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0264,-0.0529 v -0.0265 -0.0265 -0.0529 -0.21167 -0.0794 l 0.0265,-0.13229 0.0529,-0.10584 v -0.13229 -0.23812 -0.0794 l 0.0265,-0.0794 v -0.0265 l 0.0265,-0.0265 0.0529,-0.0265 h 0.0265 l 0.0265,0.0265 0.13229,0.0529 0.0265,0.0265 0.26458,0.0794 0.13229,-0.0265 h 0.0794 l 0.52917,0.13229 h 0.0794 0.0265 l 0.0265,-0.0264 0.29104,-0.15875 0.15875,-0.0529 0.0529,-0.0265 h 0.0529 l 0.0265,0.0265 h 0.0265 l 0.0265,0.0265 0.0794,0.15875 0.0265,0.15875 0.0529,0.0794 0.0265,0.0794 0.0794,0.0529 1.05833,0.42334 0.0794,-0.0265 h 0.0529 l 0.10583,0.0265 0.10583,0.0794 0.18521,0.0794 0.10583,0.0265 0.0794,0.0265 0.0265,-0.0265 h 0.0794 l 0.0529,-0.0265 h 0.0265 l 0.10583,-0.0794 0.10583,-0.10583 0.0529,-0.0265 0.26458,-0.10583 0.18521,-0.0265 0.29104,0.0794 h 0.0794 l 1.16417,-0.0265 0.0264,0.42333 -0.0264,0.18521 -0.18521,0.66146 -0.0265,0.0529 v 0.0529 l 0.10583,0.635 0.0265,0.44979 v 0.23813 0.10583 l -0.0265,0.0265 -0.34396,-0.0529 -0.0794,0.0265 -0.0265,0.0529 -0.15875,0.58208 -0.0265,0.0529 -0.0265,0.0529 h -0.0265 -0.0794 -0.0794 l -0.0265,0.0265 h -0.0529 l -0.0265,0.10583 -0.0794,0.26459 -0.18521,0.76729 -0.0794,0.15875 -0.0264,0.0265 h -0.0529 l -0.0265,0.0529 -0.0529,0.0529 -0.0264,0.0529 -0.0265,0.0529 -0.0794,0.34396 -0.0529,0.26459 -0.15875,0.44979 -0.10584,0.13229 -0.0265,0.0265 -0.13229,0.37041 -0.0265,0.0529 -0.0264,0.0265 -0.1323,0.0265 -0.0529,0.0265 -0.0794,0.0794 -0.26458,0.3175 -0.0794,0.0794 -0.34395,0.39687 0.0264,0.0265 0.0794,0.0529 0.10583,0.0265 h 0.0794 l 0.10583,0.0529 0.10583,0.0794 h 0.0265 v 0.0529 l -0.0265,0.0529 -0.0529,0.10583 -0.0265,0.0529 -0.0265,0.0265 -0.0529,0.0265 -0.0265,0.0265 h -0.0265 -0.0265 -0.0529 -0.10583 l -0.0265,0.0265 h -0.0529 l -0.0529,0.18521 v 0.13229 l 0.23812,0.55563 0.13229,0.3175 0.0265,0.0794 h -0.0265 -0.0794 l -0.0265,0.0265 -0.0529,0.0529 -0.15875,0.50271 -0.0529,0.10583 -0.0529,0.0529 -0.0529,0.0265 h -0.0794 -0.0529 l -0.0794,-0.0265 h -0.0529 -0.0265 l -0.0794,0.0265 -0.0529,0.0529 -0.15875,0.0794 h -0.10583 -0.0794 l -0.0794,-0.0265 -0.10584,-0.0529 -0.10583,-0.0794 -0.10583,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 h -0.0529 -0.0529 l -0.44979,0.29104 -0.15875,0.18521 -0.18521,0.13229 -0.0529,0.0265 -0.13229,0.21167 -0.1323,0.29104 -0.0265,0.0529 -0.0794,-0.0264 -0.44979,-0.58209 -0.0529,-0.0794 -0.0794,-0.23813 -0.0529,-0.3175 -0.0529,-0.18521 -0.0264,-0.0265 -0.0529,-0.0529 -0.23813,-0.13229 h -0.10583 l -0.0529,0.0265 -0.0529,0.0265 -0.13229,0.0529 -0.10584,-0.0794 -0.0265,-0.0265 -0.0529,-0.0529 -0.0265,-0.0794 v -0.1323 l -0.0794,-0.23812 -0.0264,-0.0794 -0.0265,-0.0264 -0.0794,-0.0529 -0.0529,-0.0265 -0.0794,-0.0794 -0.0265,-0.0265 -0.0265,-0.0529 -0.0264,-0.0529 -0.0265,-0.26458 -0.0794,-0.15875 -0.18521,-0.89958 0.0529,-0.15875 v -0.0265 l -0.0265,-0.0529 h -0.0265 -0.0264 l -0.15875,-0.0265 -0.39688,-0.23812 -0.0794,-0.0265 -0.18521,-0.0265 -0.10584,-0.0265 -0.10583,-0.0264 -0.0529,-0.0529 -0.0264,-0.0265 v -0.0265 l 0.0264,-0.0265 0.1323,-0.21166 0.10583,-0.23813 0.15875,-0.18521 0.13229,-0.10583 0.10583,-0.13229 0.0265,-0.0529 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 0.10583,-0.18521 0.13229,-0.1852 0.18521,-0.18521 0.37042,-0.39688 0.15875,-0.23812 0.0529,-0.15875 0.0265,-0.0794 v -0.0794 -0.34396 -0.0794 l 0.0794,-0.37041 v -0.0529 -0.0794 -0.0529 -0.0529 l -0.0265,-0.0265 v -0.0264 l -0.0265,-0.0529 h -0.0265 l -0.0265,-0.0265 -0.23812,-0.13229 -0.37042,-0.29104 -0.18521,-0.18521 -0.0794,-0.0529 -0.13229,-0.0529 -0.13229,-0.0529 h -0.10584 l -0.13229,-0.0529 -0.0794,-0.0529 -0.0794,-0.1323 -0.0265,-0.0794 v -0.0529 -0.10583 l 0.10584,-0.29104 0.13229,-0.26459 0.0265,-0.15875 0.0264,-0.13229 v -0.10583 l 0.0265,-0.13229 0.0265,-0.0794 0.0794,-0.0794 0.0529,-0.0794 0.0529,-0.0265 z"
id="ART"
name="Tucumán"
style="stroke-width:0.264583" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -1,29 +1,32 @@
#root {
/* src/App.css */
.container-legislativas2025 {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@keyframes logo-spin {
@keyframes elecciones-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
.container-legislativas2025 a:nth-of-type(2) .logo {
animation: elecciones-logo-spin infinite 20s linear;
}
}
.card {
.container-legislativas2025 .card {
padding: 2em;
}
.read-the-docs {
.container-legislativas2025 .read-the-docs {
color: #888;
}

View File

@@ -1,6 +1,5 @@
// src/App.tsx
import './App.css'
import { BancasWidget } from './components/BancasWidget'
/*import { BancasWidget } from './components/BancasWidget'
import { CongresoWidget } from './components/CongresoWidget'
import MapaBsAs from './components/MapaBsAs'
import { DipSenTickerWidget } from './components/DipSenTickerWidget'
@@ -18,10 +17,11 @@ import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidge
import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget'
import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget'
import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget'
*/
function App() {
return (
return ({/*
<>
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
<main className="space-y-6">
<ResumenGeneralWidget />
@@ -60,7 +60,7 @@ function App() {
<hr className="border-gray-300" />
<ResultadosRankingMunicipioWidget />
</main>
</>
</>*/}
)
}

View File

@@ -1,6 +1,13 @@
// src/apiService.ts
import axios from 'axios';
import type { ApiResponseRankingMunicipio, ApiResponseRankingSeccion, ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, ApiResponseResultadosPorSeccion } from './types/types';
import type {
ApiResponseRankingMunicipio, ApiResponseRankingSeccion,
ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple,
TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker,
ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia,
CategoriaResumenHome, ResultadoFila, ResultadoSeccion,
ProvinciaResumen
} from './types/types';
/**
* URL base para las llamadas a la API.
@@ -73,7 +80,6 @@ export interface BancadaDetalle {
export interface ConfiguracionPublica {
TickerResultadosCantidad?: string;
ConcejalesResultadosCantidad?: string;
// ... otras claves públicas que pueda añadir en el futuro
}
export interface ResultadoDetalleSeccion {
@@ -84,14 +90,46 @@ export interface ResultadoDetalleSeccion {
color: string | null;
}
export const getResumenProvincial = async (): Promise<CategoriaResumen[]> => {
const response = await apiClient.get('/resultados/provincia/02');
export interface PartidoComposicionNacional {
id: string;
nombre: string;
nombreCorto: string | null;
color: string | null;
bancasFijos: number;
bancasGanadas: number;
bancasTotales: number;
ordenDiputadosNacionales: number | null;
ordenSenadoresNacionales: number | null;
}
export interface CamaraComposicionNacional {
camaraNombre: string;
totalBancas: number;
bancasEnJuego: number;
partidos: PartidoComposicionNacional[];
presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null;
ultimaActualizacion: string;
}
export interface ComposicionNacionalData {
diputados: CamaraComposicionNacional;
senadores: CamaraComposicionNacional;
}
export interface ResumenParams {
focoDistritoId?: string;
focoCategoriaId?: number;
cantidadResultados?: number;
}
export const getResumenProvincial = async (eleccionId: number): Promise<CategoriaResumen[]> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/provincia/02`);
return response.data;
};
export const getBancasPorSeccion = async (seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => {
const { data } = await apiClient.get(`/resultados/bancas-por-seccion/${seccionId}/${camara}`);
return data;
export const getBancasPorSeccion = async (eleccionId: number, seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => {
const { data } = await apiClient.get(`/elecciones/${eleccionId}/bancas-por-seccion/${seccionId}/${camara}`);
return data;
};
/**
@@ -140,13 +178,13 @@ export const getMesasPorEstablecimiento = async (establecimientoId: string): Pro
return response.data;
};
export const getComposicionCongreso = async (): Promise<ComposicionData> => {
const response = await apiClient.get('/resultados/composicion-congreso');
export const getComposicionCongreso = async (eleccionId: number): Promise<ComposicionData> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-congreso`);
return response.data;
};
export const getBancadasDetalle = async (): Promise<BancadaDetalle[]> => {
const response = await apiClient.get('/resultados/bancadas-detalle');
export const getBancadasDetalle = async (eleccionId: number): Promise<BancadaDetalle[]> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/bancadas-detalle`);
return response.data;
};
@@ -155,24 +193,18 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> =
return response.data;
};
export const getResultadosPorSeccion = async (seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => {
const response = await apiClient.get(`/resultados/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`);
export const getResultadosPorSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`);
return response.data;
};
export const getDetalleSeccion = async (seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => {
const response = await apiClient.get(`/resultados/seccion/${seccionId}?categoriaId=${categoriaId}`);
export const getDetalleSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/seccion/${seccionId}?categoriaId=${categoriaId}`);
return response.data;
};
export const getResultadosPorMunicipioYCategoria = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`);
return response.data.resultados;
};
export const getResultadosPorMunicipio = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`);
// La respuesta es un objeto, nosotros extraemos el array de resultados
export const getResultadosPorMunicipio = async (eleccionId: number, municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
const response = await apiClient.get(`/elecciones/${eleccionId}/partido/${municipioId}?categoriaId=${categoriaId}`);
return response.data.resultados;
};
@@ -214,3 +246,116 @@ export const getEstablecimientosPorMunicipio = async (municipioId: string): Prom
const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`);
return response.data;
};
export const getPanelElectoral = async (
eleccionId: number,
ambitoId: string | null,
categoriaId: number,
nivel: 'pais' | 'provincia' | 'municipio'
): Promise<PanelElectoralDto> => {
let url: string;
// Construimos la URL con el prefijo correcto.
if (nivel === 'pais' || !ambitoId) {
url = `/elecciones/${eleccionId}/panel`;
} else if (nivel === 'provincia') {
url = `/elecciones/${eleccionId}/panel/distrito:${ambitoId}`;
} else { // nivel === 'municipio'
url = `/elecciones/${eleccionId}/panel/municipio:${ambitoId}`;
}
url += `?categoriaId=${categoriaId}`;
try {
const { data } = await apiClient.get(url);
return data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
console.warn(`API devolvió 404 para ${url}. Devolviendo un estado vacío.`);
return {
ambitoNombre: 'Sin Datos',
mapaData: [],
resultadosPanel: [],
estadoRecuento: { participacionPorcentaje: 0, mesasTotalizadasPorcentaje: 0 },
sinDatos: true,
};
}
throw error;
}
};
export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => {
const { data } = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`);
return data;
};
// 11. Endpoint para el widget de tarjetas nacionales
export const getResumenPorProvincia = async (eleccionId: number, params: ResumenParams = {}): Promise<ResumenProvincia[]> => {
// Usamos URLSearchParams para construir la query string de forma segura y limpia
const queryParams = new URLSearchParams();
if (params.focoDistritoId) {
queryParams.append('focoDistritoId', params.focoDistritoId);
}
if (params.focoCategoriaId) {
queryParams.append('focoCategoriaId', params.focoCategoriaId.toString());
}
if (params.cantidadResultados) {
queryParams.append('cantidadResultados', params.cantidadResultados.toString());
}
const queryString = queryParams.toString();
// Añadimos la query string a la URL solo si tiene contenido
const url = `/elecciones/${eleccionId}/resumen-por-provincia${queryString ? `?${queryString}` : ''}`;
const { data } = await apiClient.get(url);
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(),
distritoId: distritoId,
categoriaId: categoriaId.toString(),
});
const url = `/elecciones/home-resumen?${queryParams.toString()}`;
const { data } = await apiClient.get(url);
return data;
};
export const getHomeResumenNacional = async (eleccionId: number, categoriaId: number): Promise<CategoriaResumenHome> => {
const queryParams = new URLSearchParams({
eleccionId: eleccionId.toString(),
categoriaId: categoriaId.toString(),
});
const url = `/elecciones/home-resumen-nacional?${queryParams.toString()}`;
const { data } = await apiClient.get(url);
return data;
};
export const getTablaConurbano = async (eleccionId: number): Promise<ResultadoFila[]> => {
const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-conurbano`);
return data;
};
export const getTablaSecciones = async (eleccionId: number): Promise<ResultadoSeccion[]> => {
const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-secciones`);
return data;
};
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;
};

View File

@@ -1,209 +0,0 @@
/* src/components/CongresoWidget.css */
.congreso-container {
display: flex;
/* Se reduce ligeramente el espacio entre el gráfico y el panel */
gap: 1rem;
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 1rem;
border-radius: 8px;
max-width: 800px;
margin: 20px auto;
font-family: "Public Sans", system-ui, sans-serif;
color: #333333;
align-items: center;
}
.congreso-grafico {
/* --- CAMBIO PRINCIPAL: Se aumenta la proporción del gráfico --- */
flex: 1 1 65%;
min-width: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.congreso-grafico svg {
width: 100%;
height: auto;
animation: fadeIn 0.8s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.congreso-summary {
/* --- CAMBIO PRINCIPAL: Se reduce la proporción del panel de datos --- */
flex: 1 1 35%;
border-left: 1px solid #e0e0e0;
/* Se reduce el padding para dar aún más espacio al gráfico */
padding-left: 1rem;
}
.congreso-summary h3 {
margin-top: 0;
font-size: 1.4em;
color: #212529;
}
.chamber-tabs {
display: flex;
margin-bottom: 1.5rem;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
}
.chamber-tabs button {
flex: 1;
padding: 0.75rem 0.5rem;
border: none;
background-color: #f8f9fa;
color: #6c757d;
font-family: inherit;
font-size: 1em;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.chamber-tabs button:first-child {
border-right: 1px solid #dee2e6;
}
.chamber-tabs button:hover {
background-color: #e9ecef;
}
.chamber-tabs button.active {
background-color: var(--primary-accent-color);
color: #ffffff;
}
.summary-metric {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
font-size: 1.1em;
}
.summary-metric strong {
font-size: 1.5em;
font-weight: 700;
color: var(--primary-accent-color);
}
.congreso-summary hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1.5rem 0;
}
.partido-lista {
list-style: none;
padding: 0;
margin: 0;
}
.partido-lista li {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
.partido-color-box {
width: 14px;
height: 14px;
border-radius: 3px;
margin-right: 10px;
flex-shrink: 0;
}
.partido-nombre {
flex-grow: 1;
}
.partido-bancas {
font-weight: 700;
font-size: 1.1em;
}
/* --- Media Query para Responsividad Móvil --- */
@media (max-width: 768px) {
.congreso-container {
flex-direction: column;
padding: 1.5rem;
}
.congreso-summary {
border-left: none;
padding-left: 0;
margin-top: 2rem;
border-top: 1px solid #e0e0e0;
padding-top: 1.5rem;
}
}
.seat-tooltip {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 5px;
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.seat-tooltip img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ccc;
}
.seat-tooltip p {
margin: 0;
font-size: 12px;
font-weight: bold;
color: #333;
}
.seat-tooltip {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 8px;
background-color: white;
}
.seat-tooltip img {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ccc;
}
.seat-tooltip p {
margin: 0;
font-size: 12px;
font-weight: bold;
color: #333;
}
#seat-tooltip.react-tooltip {
opacity: 1 !important;
background-color: white; /* Opcional: asegura un fondo sólido */
}

View File

@@ -1,23 +1,23 @@
// src/components/DevApp.tsx
import { BancasWidget } from './BancasWidget'
import { CongresoWidget } from './CongresoWidget'
import MapaBsAs from './MapaBsAs'
import { DipSenTickerWidget } from './DipSenTickerWidget'
import { TelegramaWidget } from './TelegramaWidget'
import { ConcejalesWidget } from './ConcejalesWidget'
import MapaBsAsSecciones from './MapaBsAsSecciones'
import { SenadoresWidget } from './SenadoresWidget'
import { DiputadosWidget } from './DiputadosWidget'
import { ResumenGeneralWidget } from './ResumenGeneralWidget'
import { SenadoresTickerWidget } from './SenadoresTickerWidget'
import { DiputadosTickerWidget } from './DiputadosTickerWidget'
import { ConcejalesTickerWidget } from './ConcejalesTickerWidget'
import { DiputadosPorSeccionWidget } from './DiputadosPorSeccionWidget'
import { SenadoresPorSeccionWidget } from './SenadoresPorSeccionWidget'
import { ConcejalesPorSeccionWidget } from './ConcejalesPorSeccionWidget'
import { ResultadosTablaDetalladaWidget } from './ResultadosTablaDetalladaWidget'
import { ResultadosRankingMunicipioWidget } from './ResultadosRankingMunicipioWidget'
import '../App.css';
// src/components/common/DevApp.tsx
import { BancasWidget } from '../../features/legislativas/provinciales/BancasWidget'
import { CongresoWidget } from '../../features/legislativas/provinciales/CongresoWidget'
import MapaBsAs from '../../features/legislativas/provinciales/MapaBsAs'
import { DipSenTickerWidget } from '../../features/legislativas/provinciales/DipSenTickerWidget'
import { TelegramaWidget } from '../../features/legislativas/provinciales/TelegramaWidget'
import { ConcejalesWidget } from '../../features/legislativas/provinciales/ConcejalesWidget'
import MapaBsAsSecciones from '../../features/legislativas/provinciales/MapaBsAsSecciones'
import { SenadoresWidget } from '../../features/legislativas/provinciales/SenadoresWidget'
import { DiputadosWidget } from '../../features/legislativas/provinciales/DiputadosWidget'
import { ResumenGeneralWidget } from '../../features/legislativas/provinciales/ResumenGeneralWidget'
import { SenadoresTickerWidget } from '../../features/legislativas/provinciales/SenadoresTickerWidget'
import { DiputadosTickerWidget } from '../../features/legislativas/provinciales/DiputadosTickerWidget'
import { ConcejalesTickerWidget } from '../../features/legislativas/provinciales/ConcejalesTickerWidget'
import { DiputadosPorSeccionWidget } from '../../features/legislativas/provinciales/DiputadosPorSeccionWidget'
import { SenadoresPorSeccionWidget } from '../../features/legislativas/provinciales/SenadoresPorSeccionWidget'
import { ConcejalesPorSeccionWidget } from '../../features/legislativas/provinciales/ConcejalesPorSeccionWidget'
import { ResultadosTablaDetalladaWidget } from '../../features/legislativas/provinciales/ResultadosTablaDetalladaWidget'
import { ResultadosRankingMunicipioWidget } from '../../features/legislativas/provinciales/ResultadosRankingMunicipioWidget'
import '../../App.css';
export const DevApp = () => {
@@ -38,7 +38,7 @@ export const DevApp = () => {
<DiputadosPorSeccionWidget />
<SenadoresPorSeccionWidget />
<ConcejalesPorSeccionWidget />
<CongresoWidget />
<CongresoWidget eleccionId={1} />
<BancasWidget />
<MapaBsAs />
<MapaBsAsSecciones />

View File

@@ -0,0 +1,339 @@
// src/components/common/DiputadosNacionalesLayout.tsx
import React from 'react';
import type { PartidoComposicionNacional } from '../../apiService';
// --- Interfaces Actualizadas ---
interface DiputadosNacionalesLayoutProps {
partyData: PartidoComposicionNacional[];
size?: number;
presidenteBancada?: { color: string | null } | null; // <-- Nueva Prop
}
const PRESIDENTE_SEAT_INDEX = 0; // El escaño 'seat-0' es el del presidente
export const DiputadosNacionalesLayout: React.FC<DiputadosNacionalesLayoutProps> = ({
partyData,
size = 800,
presidenteBancada, // <-- Recibimos la nueva prop
}) => {
// --- ARRAY DE 257 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" ---
const seatElements = [
<circle key="seat-0" id="seat-0" r="15.7" cy="639.5" cx="595.3" />,
<circle key="seat-1" id="seat-1" r="15.7" cy="673.1" cx="109.3" />,
<circle key="seat-2" id="seat-2" r="15.7" cy="673.1" cx="161.7" />,
<circle key="seat-3" id="seat-3" r="15.7" cy="673.5" cx="214.3" />,
<circle key="seat-4" id="seat-4" r="15.7" cy="673.2" cx="266.5" />,
<circle key="seat-5" id="seat-5" r="15.7" cy="669.5" cx="319.4" />,
<circle key="seat-6" id="seat-6" r="15.7" cy="660" cx="370.8" />,
<circle key="seat-7" id="seat-7" transform="rotate(-88.1)" r="15.7" cy="77.69" cx="-634.1" />,
<circle key="seat-8" id="seat-8" r="15.7" cy="639" cx="109.3" />,
<circle key="seat-9" id="seat-9" r="15.7" cy="639" cx="161.7" />,
<circle key="seat-10" id="seat-10" r="15.7" cy="639.2" cx="214.3" />,
<circle key="seat-11" id="seat-11" r="15.7" cy="638.8" cx="266.7" />,
<circle key="seat-12" id="seat-12" r="15.7" cy="635.1" cx="319.4" />,
<circle key="seat-13" id="seat-13" r="15.7" cy="625.7" cx="371.7" />,
<circle key="seat-14" id="seat-14" r="15.7" cy="639" cx="424.2" />,
<circle key="seat-15" id="seat-15" transform="rotate(-88.1)" r="15.7" cy="77" cx="-600.18" />,
<circle key="seat-16" id="seat-16" r="15.7" cy="600.9" cx="109.5" />,
<circle key="seat-17" id="seat-17" r="15.7" cy="603.7" cx="162.1" />,
<circle key="seat-18" id="seat-18" r="15.7" cy="598.6" cx="215" />,
<circle key="seat-19" id="seat-19" r="15.7" cy="602.6" cx="267.1" />,
<circle key="seat-20" id="seat-20" transform="rotate(-88.1)" r="15.7" cy="76.57" cx="-562.57" />,
<circle key="seat-21" id="seat-21" r="15.7" cy="566.7" cx="112.2" />,
<circle key="seat-22" id="seat-22" r="15.7" cy="570" cx="164.7" />,
<circle key="seat-23" id="seat-23" r="15.7" cy="564.5" cx="218.2" />,
<circle key="seat-24" id="seat-24" r="15.7" cy="568.6" cx="270.9" />,
<circle key="seat-25" id="seat-25" r="15.7" cy="588" cx="321.1" />,
<circle key="seat-26" id="seat-26" transform="rotate(-88.1)" r="15.7" cy="79.88" cx="-524.51" />,
<circle key="seat-27" id="seat-27" transform="rotate(-5.7)" r="15.7" cy="539.19" cx="65.05" />,
<circle key="seat-28" id="seat-28" r="15.7" cy="535.9" cx="170" />,
<circle key="seat-29" id="seat-29" transform="rotate(-88.1)" r="15.7" cy="86.87" cx="-488.2" />,
<circle key="seat-30" id="seat-30" r="15.7" cy="497.2" cx="125.2" />,
<circle key="seat-31" id="seat-31" r="15.7" cy="502.8" cx="178.2" />,
<circle key="seat-32" id="seat-32" r="15.7" cy="525.1" cx="226.3" />,
<circle key="seat-33" id="seat-33" r="15.7" cy="533.1" cx="278.4" />,
<circle key="seat-34" id="seat-34" r="15.7" cy="554.6" cx="327.1" />,
<circle key="seat-35" id="seat-35" r="15.7" cy="567.9" cx="377.9" />,
<circle key="seat-36" id="seat-36" r="15.7" cy="596.7" cx="426" />,
<circle key="seat-37" id="seat-37" r="15.7" cy="453.8" cx="79.7" />,
<circle key="seat-38" id="seat-38" r="15.7" cy="462" cx="135.7" />,
<circle key="seat-39" id="seat-39" r="15.7" cy="469.3" cx="188.9" />,
<circle key="seat-40" id="seat-40" r="15.7" cy="492.6" cx="236.4" />,
<circle key="seat-41" id="seat-41" r="15.7" cy="500.6" cx="289.8" />,
<circle key="seat-42" id="seat-42" r="15.7" cy="511.6" cx="341.5" />,
<circle key="seat-43" id="seat-43" r="15.7" cy="535" cx="388.9" />,
<circle key="seat-44" id="seat-44" r="15.7" cy="555" cx="437.3" />,
<circle key="seat-45" id="seat-45" r="15.7" cy="419.3" cx="92.8" />,
<circle key="seat-46" id="seat-46" r="15.7" cy="429.8" cx="148.1" />,
<circle key="seat-47" id="seat-47" r="15.7" cy="387.4" cx="106.8" />,
<circle key="seat-48" id="seat-48" transform="rotate(-5.7)" r="15.7" cy="364.72" cx="89.86" />,
<circle key="seat-49" id="seat-49" r="15.7" cy="395.5" cx="164.4" />,
<circle key="seat-50" id="seat-50" r="15.7" cy="437.3" cx="202.4" />,
<circle key="seat-51" id="seat-51" r="15.7" cy="455.4" cx="252.1" />,
<circle key="seat-52" id="seat-52" r="15.7" cy="325.1" cx="144.9" />,
<circle key="seat-53" id="seat-53" r="15.7" cy="365.7" cx="181.3" />,
<circle key="seat-54" id="seat-54" r="15.7" cy="405.1" cx="218.8" />,
<circle key="seat-55" id="seat-55" r="15.7" cy="425.6" cx="267.7" />,
<circle key="seat-56" id="seat-56" r="15.7" cy="464.9" cx="306.5" />,
<circle key="seat-57" id="seat-57" r="15.7" cy="292.1" cx="168.7" />,
<circle key="seat-58" id="seat-58" r="15.7" cy="334.6" cx="202.3" />,
<circle key="seat-59" id="seat-59" r="15.7" cy="376.9" cx="236.7" />,
<circle key="seat-60" id="seat-60" r="15.7" cy="265.1" cx="190.8" />,
<circle key="seat-61" id="seat-61" r="15.7" cy="307.2" cx="224" />,
<circle key="seat-62" id="seat-62" r="15.7" cy="346.9" cx="259.3" />,
<circle key="seat-63" id="seat-63" r="15.7" cy="393" cx="289.6" />,
<circle key="seat-64" id="seat-64" r="15.7" cy="435.9" cx="323.7" />,
<circle key="seat-65" id="seat-65" r="15.7" cy="480.8" cx="357.3" />,
<circle key="seat-66" id="seat-66" r="15.7" cy="236.2" cx="218.1" />,
<circle key="seat-67" id="seat-67" r="15.7" cy="278.6" cx="250" />,
<circle key="seat-68" id="seat-68" r="15.7" cy="320.2" cx="283" />,
<circle key="seat-69" id="seat-69" r="15.7" cy="362" cx="315.5" />,
<circle key="seat-70" id="seat-70" r="15.7" cy="403.8" cx="348.7" />,
<circle key="seat-71" id="seat-71" r="15.7" cy="445.9" cx="381.6" />,
<circle key="seat-72" id="seat-72" r="15.7" cy="489" cx="415.1" />,
<circle key="seat-73" id="seat-73" r="15.7" cy="515.6" cx="460.7" />,
<circle key="seat-74" id="seat-74" r="15.7" cy="485.2" cx="491" />,
<circle key="seat-75" id="seat-75" r="15.7" cy="213.6" cx="243.2" />,
<circle key="seat-76" id="seat-76" r="15.7" cy="254.9" cx="275.3" />,
<circle key="seat-77" id="seat-77" r="15.7" cy="296.4" cx="307.8" />,
<circle key="seat-78" id="seat-78" r="15.7" cy="337.6" cx="339.9" />,
<circle key="seat-79" id="seat-79" r="15.7" cy="379" cx="372.5" />,
<circle key="seat-80" id="seat-80" r="15.7" cy="420.8" cx="405.1" />,
<circle key="seat-81" id="seat-81" r="15.7" cy="462.7" cx="437.2" />,
<circle key="seat-82" id="seat-82" r="15.5" cy="181.8" cx="283.1" />,
<circle key="seat-83" id="seat-83" r="15.5" cy="223.6" cx="315.4" />,
<circle key="seat-84" id="seat-84" r="15.7" cy="262.6" cx="351" />,
<circle key="seat-85" id="seat-85" r="15.5" cy="304.5" cx="382.7" />,
<circle key="seat-86" id="seat-86" r="15.7" cy="339.1" cx="425.3" />,
<circle key="seat-87" id="seat-87" r="15.7" cy="379" cx="461" />,
<circle key="seat-88" id="seat-88" r="15.7" cy="420.4" cx="495.9" />,
<circle key="seat-89" id="seat-89" r="15.7" cy="463.5" cx="528.1" />,
<circle key="seat-90" id="seat-90" r="15.5" cy="160.4" cx="315.7" />,
<circle key="seat-91" id="seat-91" r="15.5" cy="206.2" cx="342.9" />,
<circle key="seat-92" id="seat-92" r="15.7" cy="245.1" cx="379" />,
<circle key="seat-93" id="seat-93" r="15.5" cy="287.4" cx="410.5" />,
<circle key="seat-94" id="seat-94" r="15.7" cy="323.4" cx="455.9" />,
<circle key="seat-95" id="seat-95" transform="rotate(-80.8)" r="15.7" cy="555.93" cx="-274.27" />,
<circle key="seat-96" id="seat-96" r="15.7" cy="407.6" cx="527.7" />,
<circle key="seat-97" id="seat-97" r="15.5" cy="142.7" cx="345.9" />,
<circle key="seat-98" id="seat-98" r="15.5" cy="186.8" cx="375.8" />,
<circle key="seat-99" id="seat-99" r="15.5" cy="125.9" cx="377.8" />,
<circle key="seat-100" id="seat-100" r="15.5" cy="173.7" cx="405.1" />,
<circle key="seat-101" id="seat-101" r="15.7" cy="223" cx="422.9" />,
<circle key="seat-102" id="seat-102" r="15.5" cy="270.9" cx="444.3" />,
<circle key="seat-103" id="seat-103" r="15.5" cy="112" cx="409.4" />,
<circle key="seat-104" id="seat-104" r="15.5" cy="157.7" cx="438.1" />,
<circle key="seat-105" id="seat-105" r="15.7" cy="209" cx="453.9" />,
<circle key="seat-106" id="seat-106" r="15.5" cy="259.6" cx="474.2" />,
<circle key="seat-107" id="seat-107" r="15.7" cy="306.3" cx="499.3" />,
<circle key="seat-108" id="seat-108" r="15.5" cy="100.1" cx="443.4" />,
<circle key="seat-109" id="seat-109" r="15.5" cy="146.7" cx="472.7" />,
<circle key="seat-110" id="seat-110" r="15.7" cy="197.9" cx="497" />,
<circle key="seat-111" id="seat-111" r="15.5" cy="249" cx="508.8" />,
<circle key="seat-112" id="seat-112" r="15.7" cy="298.4" cx="532.7" />,
<circle key="seat-113" id="seat-113" r="15.7" cy="350.8" cx="538.1" />,
<circle key="seat-114" id="seat-114" r="15.5" cy="92.2" cx="477" />,
<circle key="seat-115" id="seat-115" r="15.5" cy="84.4" cx="510" />,
<circle key="seat-116" id="seat-116" transform="rotate(-80.8)" r="15.5" cy="523.04" cx="-55.62" />,
<circle key="seat-117" id="seat-117" r="15.7" cy="190.1" cx="531.6" />,
<circle key="seat-118" id="seat-118" r="15.5" cy="243.4" cx="542.3" />,
<circle key="seat-119" id="seat-119" r="15.5" cy="80.7" cx="544.3" />,
<circle key="seat-120" id="seat-120" r="15.5" cy="136.1" cx="541.9" />,
<circle key="seat-121" id="seat-121" r="15.5" cy="78.5" cx="579" />,
<circle key="seat-122" id="seat-122" r="15.5" cy="135" cx="578.2" />,
<circle key="seat-123" id="seat-123" r="15.7" cy="187.6" cx="577.9" />,
<circle key="seat-124" id="seat-124" r="15.5" cy="240" cx="579" />,
<circle key="seat-125" id="seat-125" r="15.7" cy="292.6" cx="578" />,
<circle key="seat-126" id="seat-126" r="15.7" cy="345.3" cx="578" />,
<circle key="seat-127" id="seat-127" r="15.7" cy="398" cx="577.8" />,
<circle key="seat-128" id="seat-128" r="15.7" cy="451.2" cx="572.2" />,
<circle key="seat-129" id="seat-129" r="15.5" cy="78.5" cx="613.5" />,
<circle key="seat-130" id="seat-130" r="15.5" cy="135" cx="612.3" />,
<circle key="seat-131" id="seat-131" r="15.7" cy="187.6" cx="612.6" />,
<circle key="seat-132" id="seat-132" r="15.5" cy="240" cx="611.5" />,
<circle key="seat-133" id="seat-133" r="15.7" cy="292.6" cx="612.5" />,
<circle key="seat-134" id="seat-134" r="15.7" cy="345.3" cx="612.5" />,
<circle key="seat-135" id="seat-135" r="15.7" cy="398" cx="612.7" />,
<circle key="seat-136" id="seat-136" r="15.7" cy="451.2" cx="618.3" />,
<circle key="seat-137" id="seat-137" r="15.5" cy="82.6" cx="646.3" />,
<circle key="seat-138" id="seat-138" r="15.5" cy="86.4" cx="680.5" />,
<circle key="seat-139" id="seat-139" r="15.5" cy="138.4" cx="650.6" />,
<circle key="seat-140" id="seat-140" r="15.5" cy="94.2" cx="715.6" />,
<circle key="seat-141" id="seat-141" r="15.5" cy="142.6" cx="685.4" />,
<circle key="seat-142" id="seat-142" r="15.7" cy="190.1" cx="657" />,
<circle key="seat-143" id="seat-143" r="15.5" cy="243.4" cx="648.3" />,
<circle key="seat-144" id="seat-144" r="15.5" cy="104.1" cx="747.1" />,
<circle key="seat-145" id="seat-145" r="15.5" cy="150.7" cx="719.9" />,
<circle key="seat-146" id="seat-146" r="15.7" cy="197.9" cx="691.5" />,
<circle key="seat-147" id="seat-147" r="15.5" cy="248.5" cx="679.8" />,
<circle key="seat-148" id="seat-148" r="15.7" cy="298.4" cx="657.8" />,
<circle key="seat-149" id="seat-149" r="15.7" cy="350.8" cx="652.4" />,
<circle key="seat-150" id="seat-150" r="15.5" cy="116" cx="783.1" />,
<circle key="seat-151" id="seat-151" r="15.5" cy="159.7" cx="750.4" />,
<circle key="seat-152" id="seat-152" r="15.7" cy="211" cx="736.6" />,
<circle key="seat-153" id="seat-153" r="15.5" cy="259.6" cx="716.4" />,
<circle key="seat-154" id="seat-154" r="15.7" cy="306.3" cx="691.2" />,
<circle key="seat-155" id="seat-155" r="15.5" cy="127.9" cx="812.8" />,
<circle key="seat-156" id="seat-156" r="15.5" cy="173.7" cx="785.5" />,
<circle key="seat-157" id="seat-157" r="15.7" cy="223" cx="767.7" />,
<circle key="seat-158" id="seat-158" r="15.5" cy="270.9" cx="746.3" />,
<circle key="seat-159" id="seat-159" r="15.5" cy="144.7" cx="846.6" />,
<circle key="seat-160" id="seat-160" r="15.5" cy="186.8" cx="814.8" />,
<circle key="seat-161" id="seat-161" r="15.5" cy="160.4" cx="874.8" />,
<circle key="seat-162" id="seat-162" r="15.5" cy="206.2" cx="847.6" />,
<circle key="seat-163" id="seat-163" r="15.7" cy="245.1" cx="811.5" />,
<circle key="seat-164" id="seat-164" r="15.5" cy="287.4" cx="780.1" />,
<circle key="seat-165" id="seat-165" r="15.7" cy="323.4" cx="734.6" />,
<circle key="seat-166" id="seat-166" r="15.7" cy="357.8" cx="687.4" />,
<circle key="seat-167" id="seat-167" r="15.7" cy="407.6" cx="662.8" />,
<circle key="seat-168" id="seat-168" r="15.5" cy="181.8" cx="907.5" />,
<circle key="seat-169" id="seat-169" r="15.5" cy="223.6" cx="875.2" />,
<circle key="seat-170" id="seat-170" r="15.7" cy="262.6" cx="839.5" />,
<circle key="seat-171" id="seat-171" r="15.5" cy="304.3" cx="807.8" />,
<circle key="seat-172" id="seat-172" r="15.7" cy="339.1" cx="765.3" />,
<circle key="seat-173" id="seat-173" r="15.7" cy="379" cx="729.6" />,
<circle key="seat-174" id="seat-174" r="15.7" cy="420.4" cx="694.6" />,
<circle key="seat-175" id="seat-175" r="15.7" cy="463.5" cx="662.5" />,
<circle key="seat-176" id="seat-176" r="15.7" cy="485.4" cx="699.5" />,
<circle key="seat-177" id="seat-177" r="15.7" cy="213.6" cx="947.4" />,
<circle key="seat-178" id="seat-178" r="15.7" cy="254.9" cx="915.2" />,
<circle key="seat-179" id="seat-179" r="15.7" cy="296.4" cx="882.7" />,
<circle key="seat-180" id="seat-180" r="15.7" cy="337.6" cx="850.7" />,
<circle key="seat-181" id="seat-181" r="15.7" cy="379" cx="818.1" />,
<circle key="seat-182" id="seat-182" r="15.7" cy="420.8" cx="785.4" />,
<circle key="seat-183" id="seat-183" r="15.7" cy="462.7" cx="753.4" />,
<circle key="seat-184" id="seat-184" r="15.7" cy="515.4" cx="730.1" />,
<circle key="seat-185" id="seat-185" r="15.7" cy="236.2" cx="972.4" />,
<circle key="seat-186" id="seat-186" r="15.7" cy="278.6" cx="940.5" />,
<circle key="seat-187" id="seat-187" r="15.7" cy="320.2" cx="907.5" />,
<circle key="seat-188" id="seat-188" r="15.7" cy="362" cx="875.1" />,
<circle key="seat-189" id="seat-189" r="15.7" cy="403.8" cx="841.8" />,
<circle key="seat-190" id="seat-190" r="15.7" cy="445.9" cx="808.9" />,
<circle key="seat-191" id="seat-191" r="15.7" cy="489" cx="775.5" />,
<circle key="seat-192" id="seat-192" r="15.7" cy="265.1" cx="999.7" />,
<circle key="seat-193" id="seat-193" r="15.7" cy="307.2" cx="966.6" />,
<circle key="seat-194" id="seat-194" r="15.7" cy="346.9" cx="931.2" />,
<circle key="seat-195" id="seat-195" r="15.7" cy="393" cx="901" />,
<circle key="seat-196" id="seat-196" r="15.7" cy="435.9" cx="866.9" />,
<circle key="seat-197" id="seat-197" r="15.7" cy="480.8" cx="833.2" />,
<circle key="seat-198" id="seat-198" transform="rotate(-80.8)" r="15.7" cy="1055.16" cx="-124.85" />,
<circle key="seat-199" id="seat-199" r="15.7" cy="334.6" cx="988.2" />,
<circle key="seat-200" id="seat-200" r="15.7" cy="376.9" cx="953.8" />,
<circle key="seat-201" id="seat-201" r="15.7" cy="425.6" cx="922.8" />,
<circle key="seat-202" id="seat-202" r="15.7" cy="464.9" cx="884" />,
<circle key="seat-203" id="seat-203" r="15.7" cy="325.1" cx="1045.7" />,
<circle key="seat-204" id="seat-204" r="15.7" cy="365.7" cx="1009.2" />,
<circle key="seat-205" id="seat-205" r="15.7" cy="405.1" cx="971.7" />,
<circle key="seat-206" id="seat-206" r="15.7" cy="354.1" cx="1063.2" />,
<circle key="seat-207" id="seat-207" transform="rotate(-80.8)" r="15.7" cy="1075.78" cx="-226.25" />,
<circle key="seat-208" id="seat-208" r="15.7" cy="387.4" cx="1081.8" />,
<circle key="seat-209" id="seat-209" r="15.7" cy="421.3" cx="1095.7" />,
<circle key="seat-210" id="seat-210" r="15.7" cy="429.8" cx="1042.5" />,
<circle key="seat-211" id="seat-211" r="15.7" cy="437.3" cx="988.2" />,
<circle key="seat-212" id="seat-212" r="15.7" cy="455.4" cx="938.5" />,
<circle key="seat-213" id="seat-213" r="15.7" cy="455.8" cx="1108.8" />,
<circle key="seat-214" id="seat-214" r="15.7" cy="462" cx="1054.9" />,
<circle key="seat-215" id="seat-215" r="15.7" cy="469.3" cx="1001.6" />,
<circle key="seat-216" id="seat-216" r="15.7" cy="492.6" cx="954.1" />,
<circle key="seat-217" id="seat-217" r="15.7" cy="500.6" cx="900.8" />,
<circle key="seat-218" id="seat-218" r="15.7" cy="511.6" cx="849" />,
<circle key="seat-219" id="seat-219" r="15.7" cy="535" cx="801.6" />,
<circle key="seat-220" id="seat-220" r="15.7" cy="554.8" cx="753.3" />,
<circle key="seat-221" id="seat-221" r="15.7" cy="490.9" cx="1118" />,
<circle key="seat-222" id="seat-222" r="15.7" cy="497.2" cx="1065.3" />,
<circle key="seat-223" id="seat-223" r="15.7" cy="502.8" cx="1012.3" />,
<circle key="seat-224" id="seat-224" r="15.7" cy="525.1" cx="964.2" />,
<circle key="seat-225" id="seat-225" r="15.7" cy="533.1" cx="912.2" />,
<circle key="seat-226" id="seat-226" r="15.7" cy="554.6" cx="863.4" />,
<circle key="seat-227" id="seat-227" r="15.7" cy="567.9" cx="812.7" />,
<circle key="seat-228" id="seat-228" r="15.7" cy="596.7" cx="764.8" />,
<circle key="seat-229" id="seat-229" r="15.7" cy="528.9" cx="1126.1" />,
<circle key="seat-230" id="seat-230" r="15.7" cy="530.2" cx="1072.7" />,
<circle key="seat-231" id="seat-231" transform="rotate(-80.8)" r="15.7" cy="1092.81" cx="-365.69" />,
<circle key="seat-232" id="seat-232" r="15.7" cy="562.9" cx="1130.6" />,
<circle key="seat-233" id="seat-233" r="15.7" cy="566.7" cx="1078.3" />,
<circle key="seat-234" id="seat-234" transform="rotate(-80.8)" r="15.7" cy="1103.39" cx="-398.54" />,
<circle key="seat-235" id="seat-235" r="15.7" cy="564.5" cx="972.4" />,
<circle key="seat-236" id="seat-236" r="15.7" cy="568.6" cx="919.7" />,
<circle key="seat-237" id="seat-237" r="15.7" cy="588" cx="869.4" />,
<circle key="seat-238" id="seat-238" r="15.7" cy="602.5" cx="1133.5" />,
<circle key="seat-239" id="seat-239" r="15.7" cy="600.9" cx="1081" />,
<circle key="seat-240" id="seat-240" transform="rotate(-80.8)" r="15.7" cy="1111.41" cx="-431.3" />,
<circle key="seat-241" id="seat-241" r="15.7" cy="598.6" cx="975.6" />,
<circle key="seat-242" id="seat-242" r="15.7" cy="602.6" cx="923.4" />,
<circle key="seat-243" id="seat-243" r="15.7" cy="636.4" cx="1133.9" />,
<circle key="seat-244" id="seat-244" r="15.7" cy="639" cx="1081.3" />,
<circle key="seat-245" id="seat-245" transform="rotate(-80.8)" r="15.7" cy="1117.48" cx="-466.13" />,
<circle key="seat-246" id="seat-246" r="15.7" cy="639.2" cx="976.3" />,
<circle key="seat-247" id="seat-247" r="15.7" cy="638.8" cx="923.9" />,
<circle key="seat-248" id="seat-248" r="15.7" cy="635.1" cx="871.2" />,
<circle key="seat-249" id="seat-249" r="15.7" cy="625.7" cx="818.8" />,
<circle key="seat-250" id="seat-250" r="15.7" cy="639" cx="766.3" />,
<circle key="seat-251" id="seat-251" r="15.7" cy="673.1" cx="1081.3" />,
<circle key="seat-252" id="seat-252" transform="rotate(-80.8)" r="15.7" cy="1122.99" cx="-499.74" />,
<circle key="seat-253" id="seat-253" r="15.7" cy="673.5" cx="976.3" />,
<circle key="seat-254" id="seat-254" r="15.7" cy="673.2" cx="924" />,
<circle key="seat-255" id="seat-255" r="15.7" cy="669.5" cx="871.2" />,
<circle key="seat-256" id="seat-256" r="15.7" cy="660" cx="819.7" />,
];
let seatIndex = 1; // Empezamos a contar desde 1, ya que el 0 es presidencial
return (
<svg viewBox="0 0 1190.6 772.2" width={size} height={size * (772.2 / 1190.6)} style={{ display: 'block', margin: 'auto' }}>
<g>
{/* Renderizamos el escaño presidencial primero y por separado */}
{presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], {
fill: presidenteBancada.color || '#A9A9A9',
strokeWidth: 0.5,
})}
{partyData.map(partido => {
// Por cada partido, creamos un array combinado de sus escaños
const partySeats = [
...Array(partido.bancasFijos).fill({ isNew: false }),
...Array(partido.bancasGanadas).fill({ isNew: true })
];
return (
// Envolvemos todos los escaños de un partido en un <g>
<g
key={partido.id}
className="party-block"
data-tooltip-id="party-tooltip"
data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`}
>
{partySeats.map((seatInfo, i) => {
// Si ya no hay más plantillas de escaños, no renderizamos nada
if (seatIndex >= seatElements.length) return null;
const template = seatElements[seatIndex];
seatIndex++; // Incrementamos el contador para el siguiente escaño
// Clonamos la plantilla con el estilo apropiado
return React.cloneElement(template, {
key: `${partido.id}-${i}`,
className: 'seat-circle',
fill: partido.color || '#808080',
fillOpacity: seatInfo.isNew ? 1 : 0.3, // Opacidad para bancas previas
stroke: partido.color || '#808080',
strokeWidth: 0.5,
});
})}
</g>
);
})}
{/* Renderizamos los escaños vacíos sobrantes */}
{seatIndex < seatElements.length &&
seatElements.slice(seatIndex).map((template, i) =>
React.cloneElement(template, {
key: `empty-${i}`,
fill: '#E0E0E0',
stroke: '#ffffff',
strokeWidth: 0.5
})
)
}
</g>
</svg>
);
};

View File

@@ -1,4 +1,4 @@
// src/components/ImageWithFallback.tsx
// src/components/common/ImageWithFallback.tsx
import { useState, useEffect } from 'react';
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {

View File

@@ -1,6 +1,6 @@
// src/components/ParliamentLayout.tsx
// src/components/common/ParliamentLayout.tsx
import React, { useLayoutEffect } from 'react';
import { assetBaseUrl } from '../apiService';
import { assetBaseUrl } from '../../apiService';
import { handleImageFallback } from './imageFallback';
// Interfaces (no cambian)

View File

@@ -0,0 +1,154 @@
// src/components/common/SenadoresNacionalesLayout.tsx
import React from 'react';
import type { PartidoComposicionNacional } from '../../apiService';
// Interfaces
interface SenadoresNacionalesLayoutProps {
partyData: PartidoComposicionNacional[];
size?: number;
presidenteBancada?: { color: string | null } | null;
}
const PRESIDENTE_SEAT_INDEX = 0;
export const SenadoresNacionalesLayout: React.FC<SenadoresNacionalesLayoutProps> = ({
partyData,
size = 800,
presidenteBancada,
}) => {
// --- ARRAY DE 73 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" ---
// El asiento 0 es el presidencial, los 72 restantes son los senadores.
const seatElements = [
<circle key="seat-0" id="seat-0" r="7.1" cy="187" cx="168.6" />,
<circle key="seat-1" id="seat-1" r="7.1" cy="166" cx="21.8" />,
<circle key="seat-2" id="seat-2" r="7.1" cy="172" cx="51.5" />,
<circle key="seat-3" id="seat-3" r="7.1" cy="174.5" cx="82.7" />,
<circle key="seat-4" id="seat-4" r="7.1" cy="147.4" cx="21.5" />,
<circle key="seat-5" id="seat-5" r="7.1" cy="155.2" cx="51.8" />,
<circle key="seat-6" id="seat-6" r="7.1" cy="156.3" cx="83.4" />,
<circle key="seat-7" id="seat-7" r="7.1" cy="169.9" cx="120.9" />,
<circle key="seat-8" id="seat-8" r="7.1" cy="128.4" cx="22.8" />,
<circle key="seat-9" id="seat-9" r="7.1" cy="137.9" cx="53.2" />,
<circle key="seat-10" id="seat-10" r="7.1" cy="138.8" cx="85.5" />,
<circle key="seat-11" id="seat-11" r="7.1" cy="151.9" cx="120.9" />,
<circle key="seat-12" id="seat-12" r="7.1" cy="109" cx="25.6" />,
<circle key="seat-13" id="seat-13" r="7.1" cy="121.3" cx="57.2" />,
<circle key="seat-14" id="seat-14" r="7.1" cy="91.5" cx="34.2" />,
<circle key="seat-15" id="seat-15" r="7.1" cy="105.7" cx="64.8" />,
<circle key="seat-16" id="seat-16" r="7.1" cy="122.5" cx="92.9" />,
<circle key="seat-17" id="seat-17" r="7.1" cy="136.2" cx="128.2" />,
<circle key="seat-18" id="seat-18" r="7.1" cy="75.5" cx="45.3" />,
<circle key="seat-19" id="seat-19" r="7.1" cy="91.3" cx="75.7" />,
<circle key="seat-20" id="seat-20" r="7.1" cy="106.5" cx="106.3" />,
<circle key="seat-21" id="seat-21" r="7.1" cy="59.8" cx="57.9" />,
<circle key="seat-22" id="seat-22" r="7.1" cy="78.6" cx="89.5" />,
<circle key="seat-23" id="seat-23" r="7.1" cy="45.3" cx="73.2" />,
<circle key="seat-24" id="seat-24" r="7.1" cy="67.2" cx="104.6" />,
<circle key="seat-25" id="seat-25" r="7.1" cy="94.3" cx="121.6" />,
<circle key="seat-26" id="seat-26" r="7.1" cy="124.3" cx="141.1" />,
<circle key="seat-27" id="seat-27" r="7.1" cy="32.7" cx="90.8" />,
<circle key="seat-28" id="seat-28" r="7.1" cy="58.3" cx="120.9" />,
<circle key="seat-29" id="seat-29" r="7.1" cy="84.9" cx="139.1" />,
<circle key="seat-30" id="seat-30" r="7.1" cy="116.4" cx="157.2" />,
<circle key="seat-31" id="seat-31" r="7.1" cy="24.6" cx="109.5" />,
<circle key="seat-32" id="seat-32" r="7.1" cy="52.2" cx="138.6" />,
<circle key="seat-33" id="seat-33" r="7.1" cy="79.5" cx="157.8" />,
<circle key="seat-34" id="seat-34" r="7.1" cy="17.9" cx="128.8" />,
<circle key="seat-35" id="seat-35" r="7.1" cy="15.2" cx="147.7" />,
<circle key="seat-36" id="seat-36" r="7.1" cy="48.3" cx="156.9" />,
<circle key="seat-37" id="seat-37" r="7.1" cy="15.2" cx="192.5" />,
<circle key="seat-38" id="seat-38" r="7.1" cy="48.3" cx="183.3" />,
<circle key="seat-39" id="seat-39" r="7.1" cy="79.5" cx="182.4" />,
<circle key="seat-40" id="seat-40" r="7.1" cy="115.8" cx="182.2" />,
<circle key="seat-41" id="seat-41" r="7.1" cy="17.9" cx="211.4" />,
<circle key="seat-42" id="seat-42" r="7.1" cy="52.2" cx="201.6" />,
<circle key="seat-43" id="seat-43" r="7.1" cy="24.6" cx="230.7" />,
<circle key="seat-44" id="seat-44" r="7.1" cy="58.3" cx="219.3" />,
<circle key="seat-45" id="seat-45" r="7.1" cy="84.9" cx="201.1" />,
<circle key="seat-46" id="seat-46" r="7.1" cy="32.7" cx="249.4" />,
<circle key="seat-47" id="seat-47" r="7.1" cy="67.2" cx="235.6" />,
<circle key="seat-48" id="seat-48" r="7.1" cy="94.3" cx="218.6" />,
<circle key="seat-49" id="seat-49" r="7.1" cy="124.3" cx="199.1" />,
<circle key="seat-50" id="seat-50" r="7.1" cy="45.3" cx="267" />,
<circle key="seat-51" id="seat-51" r="7.1" cy="59.8" cx="282.3" />,
<circle key="seat-52" id="seat-52" r="7.1" cy="78.6" cx="250.7" />,
<circle key="seat-53" id="seat-53" r="7.1" cy="106.5" cx="234" />,
<circle key="seat-54" id="seat-54" r="7.1" cy="136.2" cx="212" />,
<circle key="seat-55" id="seat-55" r="7.1" cy="75.5" cx="294.9" />,
<circle key="seat-56" id="seat-56" r="7.1" cy="91.3" cx="264.5" />,
<circle key="seat-57" id="seat-57" r="7.1" cy="91.5" cx="306" />,
<circle key="seat-58" id="seat-58" r="7.1" cy="105.7" cx="275.4" />,
<circle key="seat-59" id="seat-59" r="7.1" cy="122.5" cx="247.3" />,
<circle key="seat-60" id="seat-60" r="7.1" cy="109" cx="313.5" />,
<circle key="seat-61" id="seat-61" r="7.1" cy="121.3" cx="283" />,
<circle key="seat-62" id="seat-62" r="7.1" cy="138.8" cx="254.7" />,
<circle key="seat-63" id="seat-63" r="7.1" cy="151.9" cx="219.3" />,
<circle key="seat-64" id="seat-64" r="7.1" cy="128.4" cx="317.4" />,
<circle key="seat-65" id="seat-65" r="7.1" cy="137.9" cx="287" />,
<circle key="seat-66" id="seat-66" r="7.1" cy="156.3" cx="256.8" />,
<circle key="seat-67" id="seat-67" r="7.1" cy="169.9" cx="219.3" />,
<circle key="seat-68" id="seat-68" r="7.1" cy="147.4" cx="318.7" />,
<circle key="seat-69" id="seat-69" r="7.1" cy="155.2" cx="288.4" />,
<circle key="seat-70" id="seat-70" r="7.1" cy="166" cx="318.4" />,
<circle key="seat-71" id="seat-71" r="7.1" cy="172" cx="288.7" />,
<circle key="seat-72" id="seat-72" r="7.1" cy="174.5" cx="257.5" />,
];
let seatIndex = 1; // Empezamos desde 1 porque el 0 es para el presidente
return (
<svg viewBox="0 0 340.2 220.5" width={size} height={size * (220.5 / 340.2)} style={{ display: 'block', margin: 'auto' }}>
<g>
{/* Renderizamos primero el escaño del presidente por separado */}
{presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], {
fill: presidenteBancada.color || '#A9A9A9',
strokeWidth: 0.5,
})}
{/* Mapeamos los partidos para crear los bloques */}
{partyData.map(partido => {
const partySeats = [
...Array(partido.bancasFijos).fill({ isNew: false }),
...Array(partido.bancasGanadas).fill({ isNew: true })
];
return (
<g
key={partido.id}
className="party-block"
data-tooltip-id="party-tooltip"
data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`}
>
{partySeats.map((seatInfo, i) => {
if (seatIndex >= seatElements.length) return null;
const template = seatElements[seatIndex];
seatIndex++;
return React.cloneElement(template, {
key: `${partido.id}-${i}`,
className: 'seat-circle',
fill: partido.color || '#808080',
fillOpacity: seatInfo.isNew ? 1 : 0.3,
stroke: partido.color || '#808080',
strokeWidth: 0.5,
});
})}
</g>
);
})}
{/* Renderizamos escaños vacíos si sobran */}
{seatIndex < seatElements.length &&
seatElements.slice(seatIndex).map((template, i) =>
React.cloneElement(template, {
key: `empty-${i}`,
fill: '#E0E0E0',
stroke: '#ffffff',
strokeWidth: 0.5
})
)
}
</g>
</svg>
);
};

View File

@@ -1,7 +1,7 @@
// src/components/SenateLayout.tsx
// src/components/common/SenateLayout.tsx
import React, { useLayoutEffect } from 'react';
import { handleImageFallback } from './imageFallback';
import { assetBaseUrl } from '../apiService';
import { assetBaseUrl } from '../../apiService';
// Interfaces
interface SeatFillData {

View File

@@ -1,4 +1,4 @@
// src/components/imageFallback.ts
// src/components/common/imageFallback.ts
export function handleImageFallback(selector: string, fallbackImageUrl: string) {
// Le decimos a TypeScript que el resultado será una lista de elementos de imagen HTML

View File

@@ -0,0 +1,215 @@
// src/features/legislativas/nacionales/DevAppLegislativas.tsx
import { useState } from 'react'; // <-- Importar useState
import { ResultadosNacionalesCardsWidget } from './nacionales/ResultadosNacionalesCardsWidget';
import { CongresoNacionalWidget } from './nacionales/CongresoNacionalWidget';
import { PanelNacionalWidget } from './nacionales/PanelNacionalWidget';
import { HomeCarouselWidget } from './nacionales/HomeCarouselWidget';
import './DevAppStyle.css'
import { HomeCarouselNacionalWidget } from './nacionales/HomeCarouselNacionalWidget';
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 }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="collapsible-container">
<div className={`collapsible-content ${isExpanded ? 'expanded' : ''}`}>
{children}
</div>
<button className="toggle-button" onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? 'Mostrar Menos' : 'Mostrar Más'}
</button>
</div>
);
};
export const DevAppLegislativas = () => {
// Estilos para los separadores y descripciones para mejorar la legibilidad
const sectionStyle = {
border: '2px solid #007bff',
borderRadius: '8px',
padding: '2px',
marginTop: '3rem',
marginBottom: '3rem',
backgroundColor: '#f8f9fa'
};
const descriptionStyle = {
fontFamily: 'sans-serif',
color: '#333',
lineHeight: 1.6
};
const codeStyle = {
backgroundColor: '#e9ecef',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'Roboto'
};
return (
<div className="container-legislativas2025">
<h1>Visor de Widgets</h1>
<CongresoNacionalWidget eleccionId={2} />
<PanelNacionalWidget eleccionId={2} />
<div style={sectionStyle}>
<h2>Widget: Carrusel de Resultados Provincias (Home)</h2>
<p style={descriptionStyle}>
Uso: <code style={codeStyle}>&lt;HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={3} titulo="Diputados - Provincia de Buenos Aires" mapLinkUrl={''} /&gt;</code>
</p>
<HomeCarouselWidget
eleccionId={2} // Nacional
distritoId="02" // Buenos Aires
categoriaId={3} // Diputados Nacionales
titulo="Diputados - Provincia de Buenos Aires"
mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios"
/>
</div>
<div style={sectionStyle}>
<h2>Widget: Carrusel de Resultados Nación (Home)</h2>
<p style={descriptionStyle}>
Uso: <code style={codeStyle}>&lt;HomeCarouselNacionalWidget eleccionId={2} categoriaId={3} titulo="Diputados - Total País" mapLinkUrl={''} /&gt;</code>
</p>
<HomeCarouselNacionalWidget
eleccionId={2}
categoriaId={3} // 3 para Diputados, 2 para Senadores
titulo="Diputados - Total País"
mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios"
/>
</div>
<div style={sectionStyle}>
<h2>Widget: Carrusel de Resultados Nación (Home)</h2>
<p style={descriptionStyle}>
Uso: <code style={codeStyle}>&lt;HomeCarouselNacionalWidget eleccionId={2} categoriaId={2} titulo="Senadores - Total País" /&gt;</code>
</p>
<HomeCarouselNacionalWidget
eleccionId={2}
categoriaId={2} // 3 para Diputados, 2 para Senadores
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}>&lt;HomeCarouselProvincialWidget eleccionId={2} categoriaId={3} titulo="Diputados" /&gt;</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}>&lt;HomeCarouselProvincialWidget eleccionId={2} categoriaId={2} titulo="Senadores" /&gt;</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 --- */}
<div style={sectionStyle}>
<h2>Widget: Resultados por Provincia (Tarjetas)</h2>
<hr />
<h3 style={{ marginTop: '2rem' }}>1. Vista por Defecto</h3>
<p style={descriptionStyle}>
Sin parámetros adicionales. Muestra todas las provincias, con sus categorías correspondientes (Diputados para las 24, Senadores para las 8 que renuevan). Muestra los 2 principales partidos por defecto.
<br />
Uso: <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} /&gt;</code>
</p>
<CollapsibleWidgetWrapper>
<ResultadosNacionalesCardsWidget eleccionId={2} />
</CollapsibleWidgetWrapper>
<hr style={{ marginTop: '2rem' }} />
<h3 style={{ marginTop: '2rem' }}>2. Filtrado por Provincia (focoDistritoId)</h3>
<p style={descriptionStyle}>
Muestra únicamente la tarjeta de una provincia específica. Ideal para páginas de noticias locales. El ID de distrito ("02" para Bs. As., "06" para Chaco) se pasa como prop.
<br />
Ejemplo Buenos Aires: <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" />
<p style={{ ...descriptionStyle, marginTop: '2rem' }}>
Ejemplo Chaco (que también renueva Senadores): <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" />
<hr style={{ marginTop: '2rem' }} />
<h3 style={{ marginTop: '2rem' }}>3. Filtrado por Categoría (focoCategoriaId)</h3>
<p style={descriptionStyle}>
Muestra todas las provincias que votan para una categoría específica.
<br />
Ejemplo Senadores (ID 2): <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} />
<hr style={{ marginTop: '2rem' }} />
<h3 style={{ marginTop: '2rem' }}>4. Indicando Cantidad de Resultados (cantidadResultados)</h3>
<p style={descriptionStyle}>
Controla cuántos partidos se muestran en cada categoría. Por defecto son 2.
<br />
Ejemplo mostrando el TOP 3 de cada categoría: <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} /&gt;</code>
</p>
<CollapsibleWidgetWrapper>
<ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} />
</CollapsibleWidgetWrapper>
<hr style={{ marginTop: '2rem' }} />
<h3 style={{ marginTop: '2rem' }}>5. Mostrando las Bancas (mostrarBancas)</h3>
<p style={descriptionStyle}>
Útil para contextos donde importan las bancas. La prop <code style={codeStyle}>mostrarBancas</code> se establece en <code style={codeStyle}>true</code>.
<br />
Ejemplo en Tierra del Fuego: <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} />
<hr style={{ marginTop: '2rem' }} />
<h3 style={{ marginTop: '2rem' }}>6. Combinación de Parámetros</h3>
<p style={descriptionStyle}>
Se pueden combinar todos los parámetros para vistas muy específicas.
<br />
Ejemplo: Mostrar el TOP 1 (el ganador) para la categoría de SENADORES en la provincia de RÍO NEGRO (Distrito ID "16").
<br />
Uso: <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} />
<div style={sectionStyle}>
<h2>Widget: Tabla de Resultados del Conurbano</h2>
<TablaConurbanoWidget />
</div>
<div style={sectionStyle}>
<h2>Widget: Tabla de Resultados por Sección Electoral</h2>
<TablaSeccionesWidget />
</div>
<div style={sectionStyle}>
<h2>Resumen Nacional de Resultados por Provincia</h2>
<ResumenNacionalWidget />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,50 @@
.container-legislativas2025{
text-align: center;
}
/* --- ESTILOS PARA EL CONTENEDOR COLAPSABLE --- */
.collapsible-container {
position: relative;
padding-bottom: 50px; /* Espacio para el botón de expandir */
}
.collapsible-content {
max-height: 950px; /* Altura suficiente para 2 filas de tarjetas (aprox) */
overflow: hidden;
transition: max-height 0.7s ease-in-out;
position: relative;
}
.collapsible-content.expanded {
max-height: 100%; /* Un valor grande para asegurar que todo el contenido sea visible */
}
/* Pseudo-elemento para crear un degradado y sugerir que hay más contenido */
.collapsible-content:not(.expanded)::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 150px;
background: linear-gradient(to top, rgba(248, 249, 250, 1) 20%, rgba(248, 249, 250, 0));
pointer-events: none; /* Permite hacer clic a través del degradado */
}
.toggle-button {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
font-size: 1rem;
font-weight: bold;
color: #fff;
background-color: #007bff;
border: none;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 2;
}

View File

@@ -0,0 +1,198 @@
/* src/features/legislativas/nacionales/CongresoNacionalWidget.module.css */
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
.congresoContainer,
.congresoContainer * {
font-family: "Roboto", system-ui, sans-serif !important;
box-sizing: border-box;
}
/* --- Reseteos Generales --- */
.congresoContainer h1, .congresoContainer h2, .congresoContainer h3, .congresoContainer h4, .congresoContainer h5, .congresoContainer h6, .congresoContainer div, .congresoContainer p, .congresoContainer strong, .congresoContainer em, .congresoContainer b, .congresoContainer i {
line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline; border: 0;
}
.congresoContainer span{
line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline;
}
/* --- ESTILOS BASE (VISTA ANCHA/ESCRITORIO) --- */
.congresoContainer {
display: flex; flex-direction: row; align-items: stretch; gap: 1.5rem;
background-color: #ffffff; border: 1px solid #e0e0e0; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 1rem; border-radius: 8px; max-width: 900px; margin: 20px auto;
color: #333333; --primary-accent-color: #007bff; height: 500px;
container-type: inline-size; container-name: congreso-widget;
}
.congresoGrafico { flex: 2; min-width: 300px; display: flex; flex-direction: column; }
.congresoHemicicloWrapper { flex-grow: 1; display: flex; align-items: center; justify-content: center; width: 100%; }
.congresoHemicicloWrapper.isHovering :global(.party-block:not(:hover)) { opacity: 0.3; }
.congresoGrafico svg { width: 100%; height: auto; }
.congresoFooter { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0.5rem 0 0.5rem; margin-top: auto; font-size: 0.8em; color: #666; border-top: 1px solid #eee; }
.footerLegend { display: flex; gap: 1.25rem; align-items: center; }
.footerLegendItem { display: flex; align-items: center; gap: 0.6rem; font-size: 1.1em; }
.legendIcon { display: inline-block; width: 14px; height: 14px; border-radius: 50%; }
.legendIconSolid { background-color: #888; }
.legendIconRing {
background-color: rgba(136, 136, 136, 0.3);
border: 1px solid #3a3a3a;
}
.footerTimestamp { font-weight: 500; font-size: 0.75em; text-align: right; }
.congresoSummary { flex: 1; border-left: 1px solid #e0e0e0; padding-left: 1.25rem; display: flex; flex-direction: column; justify-content: flex-start; }
.summaryHeader {
display: flex;
flex-direction: column;
align-items: center; /* Centra el título y la barra de pestañas */
gap: 0.75rem; /* Espacio vertical entre el título y las pestañas */
margin-bottom: 1rem; /* Espacio entre la cabecera y el resto del contenido */
width: 100%;
}
.congresoSummary h3 {
text-align: center;
margin: 0; /* Quitamos el margen para que el 'gap' del header lo controle */
font-size: 1.4em;
font-weight: 700;
color: #212529;
}
.chamberTabs {
display: flex;
margin-bottom: 0; /* Quitamos el margen para que el 'gap' del header lo controle */
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
width: 100%; /* Hacemos que la barra de pestañas ocupe todo el ancho del header */
}
.chamberTabs button { flex: 1; padding: 0.5rem; border: none; background-color: #f8f9fa; color: #6c757d; font-family: inherit; font-size: 1em; font-weight: 500; cursor: pointer; transition: all 0.2s ease-in-out; text-align: center; }
.chamberTabs button:first-child { border-right: 1px solid #dee2e6; }
.chamberTabs button:hover { background-color: #e9ecef; }
.chamberTabs button.active { background-color: var(--primary-accent-color); color: #ffffff; }
.summaryMetric { display: flex; justify-content: space-between; align-items: baseline; margin-top: 0.25rem; margin-bottom: 0.25rem; font-size: 1.1em; }
.summaryMetric strong { font-size: 1.25em; font-weight: 700; color: var(--primary-accent-color); }
.congresoSummary hr { border: none; border-top: 1px solid #e0e0e0; margin: 1rem 0; }
.partidoListaContainer { flex-grow: 1; overflow-y: auto; min-height: 0; }
.partidoLista { list-style: none; padding: 0; margin: 0; padding-right: 8px; }
.partidoLista li { display: flex; align-items: center; margin-bottom: 0.85rem; }
.partidoColorBox { width: 16px; height: 16px; border-radius: 4px; margin-right: 12px; flex-shrink: 0; }
.partidoNombre { flex-grow: 1; font-size: 1em; }
.partidoBancas { font-weight: 700; font-size: 1.1em; }
/* --- REGLA #1: RESPONSIVIDAD EXTERNA Y LAYOUT PRINCIPAL (MÓVIL) --- */
@media (max-width: 768px) {
.congresoContainer {
/* Forzar el comportamiento externo */
width: 100% !important;
flex-basis: 100% !important;
grid-column: 1 / -1 !important;
max-width: none !important;
margin-left: auto !important;
margin-right: auto !important;
flex-direction: column !important;
height: auto !important;
max-height: none !important;
}
}
/* --- REGLA #2: AJUSTES FINOS INTERNOS CUANDO EL WIDGET ES ESTRECHO --- */
@container congreso-widget (max-width: 700px) {
/* La dirección del flex ya fue establecida por la @media query. */
/* Aquí solo hacemos los ajustes de contenido. */
.congresoGrafico { min-width: 0; }
.congresoSummary { border-left: none; padding-left: 0; border-top: 1px solid #e0e0e0; padding-top: 1rem; margin-top: 1rem; }
.congresoSummary h3 { font-size: 1.25em; }
.summaryMetric { font-size: 1em; }
.summaryMetric strong { font-size: 1.3em; }
.partidoNombre { font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partidoBancas { font-size: 1em; }
.partidoListaContainer { overflow-y: visible; max-height: none; }
.footerLegend { gap: 1rem; }
.footerLegendItem{ font-size: 0.9em; }
.congresoFooter { flex-direction: column; align-items: center; gap: 0.75rem; padding: 0.75rem 0rem; }
}
.partidoListaContainer {
scrollbar-width: thin; /* Hace el scrollbar más delgado */
scrollbar-color: #c1c1c1 #f1f1f1; /* Color del thumb y del track */
}
.partidoListaContainer::-webkit-scrollbar {
width: 8px;
}
/* Estilo del "track" o canal por donde se mueve el scrollbar */
.partidoListaContainer::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
/* Estilo del "thumb" o la barra que se arrastra */
.partidoListaContainer::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 10px;
border: 2px solid #f1f1f1;
}
/* Estilo del "thumb" al pasar el mouse por encima */
.partidoListaContainer::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* --- A. Tooltip de FOTO DE LEGISLADOR (seat-tooltip) --- */
:global(#seat-tooltip.react-tooltip) {
opacity: 1 !important;
background-color: #ffffff !important;
border-radius: 6px !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important;
padding: 0 !important;
z-index: 9999 !important;
}
:global(.seat-tooltip) {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 8px;
}
:global(.seat-tooltip img) {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #ccc;
}
:global(.seat-tooltip p) {
margin: 0;
font-size: 12px;
font-weight: bold;
font-family: "Roboto", system-ui, sans-serif !important;
color: #333333 !important;
text-align: center !important;
}
/* --- B. ¡NUEVO! Tooltip de BLOQUE DE PARTIDO (party-tooltip) --- */
:global(#party-tooltip.react-tooltip) {
opacity: 1 !important;
background-color: #333333 !important; /* Fondo oscuro, como el nativo */
border-radius: 4px !important;
padding: 4px 8px !important; /* Padding interno */
z-index: 9998 !important; /* Ligeramente por debajo del otro por si acaso */
pointer-events: none; /* Evita que el tooltip interfiera con el mouse */
}
/* Usamos la clase que añadimos en el TSX para estilizar el contenido */
.partyTooltipContainer {
font-size: 13px !important;
font-family: "Roboto", system-ui, sans-serif !important;
color: #ffffff !important; /* Letras blancas para contrastar con el fondo oscuro */
font-weight: 500 !important;
}

View File

@@ -0,0 +1,163 @@
// src/features/legislativas/nacionales/CongresoNacionalWidget.tsx
import { useState, Suspense, useMemo } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Tooltip } from 'react-tooltip';
import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout';
import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout';
import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService';
import styles from './CongresoNacionalWidget.module.css';
interface CongresoNacionalWidgetProps {
eleccionId: number;
}
const formatTimestamp = (dateString: string) => {
if (!dateString) return '...';
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}`;
};
const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
const [camaraActiva, setCamaraActiva] = useState<'diputados' | 'senadores'>('diputados');
const [isHovering, setIsHovering] = useState(false);
const { data } = useSuspenseQuery<ComposicionNacionalData>({
queryKey: ['composicionNacional', eleccionId],
queryFn: () => getComposicionNacional(eleccionId),
refetchInterval: 180000,
});
const datosCamaraActual = data[camaraActiva];
const partidosOrdenados = useMemo(() => {
if (!datosCamaraActual?.partidos) return [];
const partidosACopiar = [...datosCamaraActual.partidos];
partidosACopiar.sort((a, b) => {
const ordenA = camaraActiva === 'diputados' ? a.ordenDiputadosNacionales : a.ordenSenadoresNacionales;
const ordenB = camaraActiva === 'diputados' ? b.ordenDiputadosNacionales : b.ordenSenadoresNacionales;
return (ordenA ?? 999) - (ordenB ?? 999);
});
return partidosACopiar;
}, [datosCamaraActual, camaraActiva]);
const partyDataParaLayout = useMemo(() => {
if (camaraActiva === 'senadores') return partidosOrdenados;
if (!partidosOrdenados || !datosCamaraActual.presidenteBancada?.color) return partidosOrdenados;
const partidoPresidente = partidosOrdenados.find(p => p.color === datosCamaraActual.presidenteBancada!.color);
if (!partidoPresidente) return partidosOrdenados;
const adjustedPartyData = JSON.parse(JSON.stringify(partidosOrdenados));
const partidoAjustar = adjustedPartyData.find((p: PartidoComposicionNacional) => p.id === partidoPresidente.id);
if (partidoAjustar) {
const tipoBanca = datosCamaraActual.presidenteBancada.tipoBanca;
if (tipoBanca === 'ganada' && partidoAjustar.bancasGanadas > 0) {
partidoAjustar.bancasGanadas -= 1;
} else if (tipoBanca === 'previa' && partidoAjustar.bancasFijos > 0) {
partidoAjustar.bancasFijos -= 1;
} else {
if (partidoAjustar.bancasGanadas > 0) {
partidoAjustar.bancasGanadas -= 1;
} else if (partidoAjustar.bancasFijos > 0) {
partidoAjustar.bancasFijos -= 1;
}
}
}
return adjustedPartyData;
}, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]);
// 2. Todas las props 'className' ahora usan el objeto 'styles'
return (
<div className={styles.congresoContainer}>
<div className={styles.congresoGrafico}>
<div
className={`${styles.congresoHemicicloWrapper} ${isHovering ? styles.isHovering : ''}`}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
{camaraActiva === 'diputados' ?
<DiputadosNacionalesLayout
partyData={partyDataParaLayout}
presidenteBancada={datosCamaraActual.presidenteBancada || null}
size={700}
/> :
<SenadoresNacionalesLayout
partyData={partyDataParaLayout}
presidenteBancada={datosCamaraActual.presidenteBancada || null}
size={700}
/>
}
</div>
<div className={styles.congresoFooter}>
<div className={styles.footerLegend}>
<div className={styles.footerLegendItem}>
<span className={`${styles.legendIcon} ${styles.legendIconSolid}`}></span>
<span>Bancas en juego</span>
</div>
<div className={styles.footerLegendItem}>
<span className={`${styles.legendIcon} ${styles.legendIconRing}`}></span>
<span>Bancas Fijas</span>
</div>
</div>
<div className={styles.footerTimestamp}>
Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)}
</div>
</div>
</div>
<div className={styles.congresoSummary}>
<div className={styles.summaryHeader}>
<h3>{datosCamaraActual.camaraNombre}</h3>
<div className={styles.chamberTabs}>
<button className={camaraActiva === 'diputados' ? styles.active : ''} onClick={() => setCamaraActiva('diputados')}>
Diputados
</button>
<button className={camaraActiva === 'senadores' ? styles.active : ''} onClick={() => setCamaraActiva('senadores')}>
Senadores
</button>
</div>
</div>
<div className={styles.summaryMetric}>
<span>Total de Bancas</span>
<strong>{datosCamaraActual.totalBancas}</strong>
</div>
<div className={styles.summaryMetric}>
<span>Bancas en Juego</span>
<strong>{datosCamaraActual.bancasEnJuego}</strong>
</div>
<hr />
<div className={styles.partidoListaContainer}>
<ul className={styles.partidoLista}>
{partidosOrdenados
.filter(p => p.bancasTotales > 0)
.map((partido: PartidoComposicionNacional) => (
<li key={partido.id}>
<span className={styles.partidoColorBox} style={{ 'marginRight': '0.25rem', backgroundColor: partido.color || '#808080' }}></span>
<span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
<strong
className={styles.partidoBancas}
title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`}
>
{partido.bancasTotales}
</strong>
</li>
))}
</ul>
</div>
</div>
<Tooltip id="party-tooltip" className={styles.partyTooltipContainer} />
</div>
);
};
export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => {
return (
<Suspense fallback={<div className={`${styles.congresoContainer} ${styles.loading}`} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}>
<WidgetContent eleccionId={eleccionId} />
</Suspense>
);
};

View File

@@ -0,0 +1,148 @@
// src/features/legislativas/nacionales/HomeCarouselNacionalWidget.tsx
import { useQuery } from '@tanstack/react-query';
import { getHomeResumenNacional } from '../../../apiService';
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../apiService';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Navigation, A11y } from 'swiper/modules';
import { TfiMapAlt } from "react-icons/tfi";
// @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;
mapLinkUrl: string;
}
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);
if (isNaN(date.getTime())) {
return 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 HomeCarouselNacionalWidget = ({ eleccionId, categoriaId, titulo, mapLinkUrl }: Props) => {
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
const prevButtonClass = `prev-${uniqueId}`;
const nextButtonClass = `next-${uniqueId}`;
const { data, isLoading, error } = useQuery({
queryKey: ['homeResumenNacional', eleccionId, categoriaId],
queryFn: () => getHomeResumenNacional(eleccionId, categoriaId),
refetchInterval: 180000,
})
if (isLoading) return <div>Cargando widget...</div>;
if (error || !data) return <div>No se pudieron cargar los datos.</div>;
return (
<div className={styles.homeCarouselWidget}>
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
<h2 className={styles.widgetTitle}>{titulo}</h2>
<a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}>
<TfiMapAlt />
<span className={styles.buttonText}>Ver Mapa</span>
</a>
</div>
<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>
);
};

View File

@@ -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, type StylesConfig } 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 | string;
titulo: string;
}
interface OptionType {
value: string;
label: string;
}
const PROVINCIAS_QUE_RENUEVAN_SENADORES = new Set(['01', '06', '08', '15', '16', '17', '22', '24']);
const CATEGORIA_SENADORES = 2;
const customSelectStyles: StylesConfig<OptionType, false> = {
menuList: (provided) => ({
...provided,
maxHeight: '180px',
}),
};
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) => {
const [selectedProvince, setSelectedProvince] = useState<OptionType | null>({ value: '01', label: 'CABA' });
const { data: provincias = [], isLoading: isLoadingProvincias } = useQuery<CatalogoItem[]>({
queryKey: ['provincias'],
queryFn: getProvincias,
});
const provinceOptions: OptionType[] = useMemo(() => {
const allOptions = provincias.map(p => ({ value: p.id, label: p.nombre }));
if (Number(categoriaId) === CATEGORIA_SENADORES) {
return allOptions.filter(opt => PROVINCIAS_QUE_RENUEVAN_SENADORES.has(opt.value));
}
return allOptions;
}, [provincias, categoriaId]);
useEffect(() => {
if (provinceOptions.length > 0) {
if (!selectedProvince) {
const defaultOption = provinceOptions.find(opt => opt.value === '01');
setSelectedProvince(defaultOption || provinceOptions[0]);
} else {
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, Number(categoriaId)),
enabled: !!selectedProvince,
refetchInterval: 180000,
});
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..."
styles={customSelectStyles}
/>
</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>
);
};

View File

@@ -0,0 +1,478 @@
/* src/features/legislativas/nacionales/HomeCarouselWidget.module.css */
.homeCarouselWidget {
--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;
}
.homeCarouselWidget,
.homeCarouselWidget * {
font-family: var(--font-family-sans) !important;
box-sizing: border-box;
}
.homeCarouselWidget {
background-color: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
max-width: 1200px;
margin: 2rem auto;
position: relative;
}
.carouselContainer {
position: relative;
}
.widgetHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.widgetTitle {
font-size: 1.2rem;
font-weight: 900;
color: var(--primary-text);
margin: 0;
padding: 0;
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 {
display: flex;
align-items: center;
gap: 0.4rem;
background-color: #007bff;
color: white;
padding: 0.5rem 1rem;
border-radius: 9999px;
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 {
font-size: 1.5rem;
}
.buttonText {
display: inline;
}
.mapLinkButton:hover {
background-color: #0056b3;
color: white;
text-decoration: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.topStatsBar {
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;
margin-top: 0.5rem;
}
.topStatsBar>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;
}
.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;
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);
border-left: 5px solid;
border-left-color: var(--candidate-color, #ccc);
position: relative;
}
.candidatePhotoWrapper {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background-color: var(--candidate-color, #e9ecef);
}
.candidatePhoto {
width: 100% !important;
height: 100% !important;
object-fit: cover;
box-sizing: border-box;
}
.candidateDetails {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
}
.candidateInfo {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.1rem;
min-width: 0;
margin-right: 0.75rem;
}
.candidateName,
.partyName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
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;
}
.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;
}
/* Estilo base para ambos botones */
.navButton {
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;
position: absolute;
top: 50%;
transform: translateY(-50%);
margin-top: 0;
z-index: 10;
cursor: pointer;
}
/* Usamos el pseudo-elemento ::after para mostrar el icono SVG como fondo */
.navButton::after {
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%;
}
/* 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+");
}
.navButtonNext {
right: -10px;
}
.navButtonNext::after {
/* SVG de flecha derecha (chevron) codificado en Base64 */
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSI5IDYgMTUgMTIgOSAxOCI+PC9wb2x5bGluZT48L3N2Zz4=");
}
/* Swiper añade esta clase al botón cuando está deshabilitado */
.navButton.swiper-button-disabled {
opacity: 0;
pointer-events: none;
}
.homeCarouselWidget :global(.swiper-slide) {
background: transparent !important;
color: initial !important;
text-align: left !important;
height: auto !important;
}
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}
/* --- INICIO DE LA MODIFICACIÓN DE FLECHAS --- */
.homeCarouselWidget :global(.swiper-button-prev),
.homeCarouselWidget :global(.swiper-button-next) {
width: 30px !important;
height: 30px !important;
background-color: rgba(255, 255, 255, 0.9) !important;
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
position: absolute !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
z-index: 10;
}
.homeCarouselWidget :global(.swiper-button-prev:after),
.homeCarouselWidget :global(.swiper-button-next:after) {
display: block !important;
font-family: 'swiper-icons';
font-size: 14px !important;
font-weight: bold !important;
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-disabled) {
opacity: 0 !important;
pointer-events: none !important;
}
.widgetFooter {
text-align: right;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.5rem;
}
.shortText {
display: none;
}
@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;
}
.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;
}
.buttonText {
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;
}
/* 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;
}
}
/* Mantenemos estos estilos globales por si acaso */
.homeCarouselWidget :global(.swiper-slide) {
background: transparent !important;
color: initial !important;
text-align: left !important;
height: auto !important;
}
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}

View File

@@ -0,0 +1,149 @@
// 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';
import { TfiMapAlt } from "react-icons/tfi";
// @ts-ignore
import 'swiper/css';
// @ts-ignore
import 'swiper/css/navigation';
import styles from './HomeCarouselWidget.module.css';
interface Props {
eleccionId: number;
distritoId: string;
categoriaId: number;
titulo: string;
mapLinkUrl: string;
}
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);
if (isNaN(date.getTime())) {
return 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 HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo, mapLinkUrl }: Props) => {
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
const prevButtonClass = `prev-${uniqueId}`;
const nextButtonClass = `next-${uniqueId}`;
const { data, isLoading, error } = useQuery({
queryKey: ['homeResumen', eleccionId, distritoId, categoriaId],
queryFn: () => getHomeResumen(eleccionId, distritoId, categoriaId),
refetchInterval: 180000,
});
if (isLoading) return <div>Cargando widget...</div>;
if (error || !data) return <div>No se pudieron cargar los datos.</div>;
return (
<div className={styles.homeCarouselWidget}>
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
<h2 className={styles.widgetTitle}>{titulo}</h2>
<a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}>
<TfiMapAlt />
<span className={styles.buttonText}>Ver Mapa</span>
</a>
</div>
<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>
);
};

View File

@@ -0,0 +1,579 @@
/* src/features/legislativas/nacionales/PanelNacional.module.css */
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
.panelNacionalContainer,
.panelNacionalContainer * {
font-family: 'Roboto', sans-serif !important;
box-sizing: border-box;
}
.panelNacionalContainer {
max-width: 900px;
margin: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
position: relative;
padding: 10px;
}
.panelHeader {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0;
position: relative;
z-index: 20;
background-color: white;
}
.headerTopRow {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 2rem;
}
/* --- ESTILOS PARA REACT-SELECT USANDO MÓDULOS --- */
.categoriaSelectorContainer {
min-width: 220px;
}
.categoriaSelectorContainer :global(.categoriaSelector__control) {
border-radius: 8px !important;
border: 1px solid #e0e0e0 !important;
box-shadow: none !important;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.categoriaSelectorContainer :global(.categoriaSelector__control--is-focused) {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
.categoriaSelectorContainer :global(.categoriaSelector__single-value) {
font-weight: 500;
color: #333;
}
.categoriaSelectorContainer :global(.categoriaSelector__menu) {
border-radius: 8px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border: 1px solid #e0e0e0 !important;
margin-top: 4px !important;
}
.categoriaSelectorContainer :global(.categoriaSelector__option) {
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
.categoriaSelectorContainer :global(.categoriaSelector__option--is-focused) {
background-color: #f0f8ff;
color: #333;
}
.categoriaSelectorContainer :global(.categoriaSelector__option--is-selected) {
background-color: #007bff;
color: white;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator-separator) {
display: none;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator) {
color: #a0a0a0;
transition: color 0.2s;
}
.categoriaSelectorContainer :global(.categoriaSelector__indicator:hover) {
color: #333;
}
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
.breadcrumbsContainer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
}
.breadcrumbItem,
.breadcrumbItemActual {
display: flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 8px;
transition: background-color 0.2s ease-in-out;
}
.breadcrumbItem {
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
color: #333;
cursor: pointer;
font-weight: 500;
}
.breadcrumbItem:hover {
background-color: #e0e0e0;
border-color: #d1d1d1;
}
.breadcrumbItemActual {
background-color: transparent;
color: #000;
font-weight: 700;
}
.breadcrumbIcon {
margin-right: 0.4rem;
font-size: 1rem;
}
.breadcrumbSeparator {
color: #a0a0a0;
font-size: 1.2rem;
}
.panelMainContent {
display: flex;
height: 75vh;
min-height: 500px;
transition: all 0.5s ease-in-out;
}
.mapaColumn {
flex: 2;
position: relative;
transition: flex 0.5s ease-in-out;
}
.resultadosColumn {
flex: 1;
overflow-y: auto;
padding: 1rem;
transition: all 0.5s ease-in-out;
min-width: 350px;
}
.partidoFila {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 1rem;
}
.partidoLogo {
flex-shrink: 0;
width: 65px;
height: 65px;
border-radius: 12px;
}
.partidoLogo img {
width: 100%;
height: 100%;
border-radius: 12px;
}
.partidoMainContent {
flex-grow: 1;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
align-items: center;
gap: 0.5rem 0.75rem; /* Aumentamos el gap vertical para más aire */
}
/* El contenedor de la barra */
.partidoBarraConVotos {
grid-column: 1 / 3;
position: relative; /* Clave para superponer el texto */
height: 28px;
background-color: #f0f0f0;
border-radius: 6px;
}
/* La barra de progreso coloreada */
.partidoBarraForeground {
height: 100%;
border-radius: 6px;
transition: width 0.5s ease-in-out;
}
/* La ÚNICA capa de texto, posicionada de forma absoluta */
.partidoVotosEnBarra {
position: absolute;
top: 0;
right: 10px;
bottom: 0;
display: flex;
align-items: center;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
color: #000;
}
.animatedNumberWrapper {
margin-right: 4px;
}
.partidoTopRow { display: contents; }
.partidoInfoWrapper {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
text-align: left;
}
.partidoNombre {
font-weight: 600;
font-size: 1rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.partidoNombreNormal {
font-size: 0.9rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.candidatoNombre {
font-size: 1rem;
font-weight: 600;
line-height: 1.1;
}
.partidoStats { flex-shrink: 0; text-align: right; padding-left: 1rem; }
.partidoPorcentaje { font-size: 1.15rem; font-weight: 700; display: block; }
.partidoBarraBackground {
height: 16px;
background-color: #f0f0f0;
border-radius: 4px;
grid-column: 1 / 3;
}
.partidoBarraForeground {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
}
.panelEstadoRecuento {
display: flex;
justify-content: space-around;
padding-bottom: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e0e0e0;
}
.estadoItem {
width: 95px;
text-align: center;
}
.estadoItem span {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #666;
display: block;
}
/* --- ESTILOS PARA MAPA --- */
/* --- INICIO DE LA CORRECCIÓN --- */
.mapaComponenteContainer {
width: 100%;
height: 100%;
position: relative; /* Esta línea es la que faltaba */
overflow: hidden;
}
/* --- FIN DE LA CORRECCIÓN --- */
.mapaRenderArea { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.mapaVolverBtn,
.zoomBtn {
background-color: #ffffff;
border: 1px solid #e0e0e0; /* Borde más sutil */
border-radius: 8px; /* Bordes más suaves */
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); /* Sombra más pronunciada y moderna */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out; /* Transición suave para todos los efectos */
color: #333;
}
.mapaVolverBtn:hover,
.zoomBtn:hover:not(:disabled) {
border-color: #007bff; /* Borde de acento */
color: #007bff; /* Icono/texto de acento */
transform: translateY(-2px); /* Efecto de "levantar" */
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
}
.mapaVolverBtn:active,
.zoomBtn:active:not(:disabled) {
transform: translateY(0px); /* Botón "presionado" */
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); /* Sombra interior */
background-color: #f8f9fa;
}
.mapaVolverBtn {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
padding: 8px 12px;
font-weight: 500;
}
:global(.rsm-zoomable-group) { transition: transform 0.75s ease-in-out; }
:global(.rsm-zoomable-group.panning) { transition: none; }
.panelMainContent.panelCollapsed .mapaColumn { flex: 1 1 100%; }
.panelMainContent.panelCollapsed .resultadosColumn {
flex-basis: 0;
min-width: 0;
max-width: 0;
padding: 0;
overflow: hidden;
}
.panelToggleBtn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
z-index: 10;
width: 30px;
height: 50px;
border: 1px solid #ccc;
background-color: white;
border-radius: 4px 0 0 4px;
cursor: pointer;
font-size: 1.3rem;
font-weight: bold;
color: #555;
display: flex;
align-items: center;
justify-content: center;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.panelToggleBtn:hover { background-color: #f0f0f0; }
:global(.rsm-geography) {
stroke: #000000;
stroke-width: 0.25px;
outline: none;
transition: filter 0.2s ease-in-out;
}
:global(.rsm-geography:not(.selected):hover) {
filter: brightness(1.25);
stroke: #ffffff;
stroke-width: 0.25px;
paint-order: stroke;
}
:global(.rsm-geography.selected) {
stroke: #000000;
stroke-width: 0.25px;
filter: none;
pointer-events: none;
}
:global(.rsm-geography-faded), :global(.rsm-geography-faded-municipality) {
opacity: 0.5;
pointer-events: none;
}
:global(.caba-comuna-geography) {
stroke: #000000;
stroke-width: 0.05px;
}
:global(.caba-comuna-geography:not(.selected):hover) {
stroke: #000000;
stroke-width: 0.055px;
filter: brightness(1.25);
}
:global(.caba-comuna-geography.selected) {
stroke: #000000;
stroke-width: 0.075px;
}
.transitionSpinner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.5);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
.transitionSpinner::after {
content: '';
width: 50px;
height: 50px;
border: 5px solid rgba(0, 0, 0, 0.2);
border-top-color: #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.cabaMagnifierContainer { position: absolute; height: auto; transform: translate(-50%, -50%); pointer-events: none; }
.cabaLupaSvg { width: 100%; height: auto; pointer-events: none; }
.cabaLupaInteractiveArea { pointer-events: all; cursor: pointer; filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25)); transition: transform 0.2s ease-in-out; }
.cabaLupaInteractiveArea:hover { filter: brightness(1.15); stroke: #ffffff; stroke-width: 0.25px; }
.skeletonFila div {
background: #f6f7f8;
background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
background-repeat: no-repeat;
background-size: 800px 104px;
animation: shimmer 1s linear infinite;
border-radius: 4px;
}
.skeletonLogo { width: 65px; height: 65px; }
.skeletonText { height: 1em; }
.skeletonBar { height: 20px; margin-top: 4px; }
.zoomControlsContainer {
position: absolute;
top: 10px;
right: 10px;
z-index: 30;
display: flex;
flex-direction: column;
gap: 8px; /* Un poco más de espacio */
}
/* Estilos específicos para los botones de zoom */
.zoomBtn {
width: 40px;
height: 40px;
}
.zoomIconWrapper svg {
width: 22px; /* Iconos ligeramente más grandes */
height: 22px;
}
/* Estilo para el botón deshabilitado */
.zoomBtn:disabled,
.zoomBtn.disabled { /* Cubrimos ambos casos */
opacity: 0.6;
cursor: not-allowed;
background-color: #f8f9fa;
}
:global(.map-locked .rsm-geography) { cursor: pointer; }
:global(.map-pannable .rsm-geography) { cursor: grab; }
.headerBottomRow {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
gap: 1rem;
}
.municipioSearchContainer { min-width: 280px; }
@media (max-width: 800px) {
.panelNacionalContainer { display: flex; flex-direction: column; height: 100vh; padding: 0; border: none; border-radius: 0; }
.panelHeader { flex-shrink: 0; padding: 1rem; border-radius: 0; }
.panelMainContent { flex-grow: 1; position: relative; height: auto; min-height: 0; }
.panelToggleBtn { display: none; }
.headerTopRow { flex-direction: column; align-items: flex-start; gap: 1rem; }
.categoriaSelectorContainer { width: 100%; }
.mapaColumn,
.resultadosColumn { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; }
.mapaColumn { z-index: 10; }
.resultadosColumn { padding: 1rem; overflow-y: auto; z-index: 15; }
.panelMainContent.mobile-view-mapa .resultadosColumn { opacity: 0; visibility: hidden; pointer-events: none; }
.panelMainContent.mobile-view-resultados .mapaColumn { opacity: 0; visibility: hidden; pointer-events: none; }
.resultadosColumn { padding: 0.5rem; padding-bottom: 50px; }
.mapaColumn .mapaComponenteContainer, .mapaColumn .mapaRenderArea { height: 100%; }
.panelPartidosContainer { padding-bottom: 0; }
.zoomControlsContainer, .mapaVolverBtn { top: 15px; }
.headerBottomRow { flex-direction: column; align-items: stretch; gap: 1rem; }
.municipioSearchContainer { min-width: 100%; }
@media (max-width: 900px) and (orientation: landscape) {
.panelMainContent { display: flex; flex-direction: row; position: static; height: 85vh; min-height: 400px; }
.mapaColumn,
.resultadosColumn { position: static; height: auto; width: auto; opacity: 1; visibility: visible; pointer-events: auto; flex: 3; overflow-y: auto; }
.resultadosColumn { flex: 2; min-width: 300px; }
.mobileResultsCardContainer { display: none; }
.panelToggleBtn { display: flex; }
}
}
.mobileResultsCardContainer {
position: absolute;
bottom: 0px;
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;
}
.mobileResultsCardContainer.view-resultados .collapsibleSection { display: none; }
.mobileResultsCardContainer.view-resultados .mobileCardViewToggle { border-top: none; }
.collapsibleSection { display: flex; flex-direction: column; }
.mobileResultsHeader { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; cursor: pointer; }
.mobileResultsHeader .headerInfo { display: flex; align-items: baseline; gap: 12px; }
.mobileResultsHeader .headerInfo h4 { margin: 0; font-size: 1.2rem; font-weight: 700; }
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.8rem; color: #6c757d; font-weight: 500; text-transform: uppercase; }
.mobileResultsHeader .headerToggleIcon { font-size: 1.5rem; color: #007bff; transition: transform 0.3s; }
.mobileResultsContent { 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; }
.mobileResultsCardContainer.expanded .mobileResultsContent { max-height: 500px; opacity: 1; padding: 5px 15px 15px 15px; border-top-color: #e0e0e0; }
.mobileResultRow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; border-left: 4px solid; padding-left: 8px; }
.mobileResultRow:last-child { border-bottom: none; }
.mobileResultLogo { flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; }
.mobileResultLogo img { width: 100%; height: 100%; border-radius: 8px; }
.mobileResultInfo { flex-grow: 1; min-width: 0; }
.mobileResultPartyName { display: block; font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobileResultCandidateName { display: block; font-size: 0.75rem; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mobileResultStats { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; }
.mobileResultStats strong { font-size: 0.95rem; font-weight: 700; }
.mobileResultStats span { font-size: 0.7rem; color: #6c757d; }
.noResultsText { padding: 1rem; text-align: center; color: #6c757d; font-size: 0.9rem; }
.mobileCardViewToggle { display: flex; padding: 5px; background-color: rgba(230, 230, 230, 0.6); border-top: 1px solid rgba(0, 0, 0, 0.08); }
.mobileCardViewToggle .toggleBtn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 15px; 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; }
.mobileCardViewToggle .toggleBtn.active { background-color: #007bff; color: white; box-shadow: 0 2px 5px rgba(0, 123, 255, 0.2); }
@media (max-width: 380px) {
.mobileResultsHeader { padding: 4px 10px; }
.mobileResultsHeader .headerInfo h4 { font-size: 0.75rem; text-transform: uppercase; }
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.7rem; }
.mobileCardViewToggle .toggleBtn { padding: 6px 10px; font-size: 0.8rem; }
}

View File

@@ -0,0 +1,283 @@
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
import { useMemo, useState, Suspense, useEffect } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { getPanelElectoral } from '../../../apiService';
import { MapaNacional } from './components/MapaNacional';
import { PanelResultados } from './components/PanelResultados';
import { Breadcrumbs } from './components/Breadcrumbs';
import { MunicipioSearch } from './components/MunicipioSearch';
import styles from './PanelNacional.module.css';
import Select from 'react-select';
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
import { useMediaQuery } from './hooks/useMediaQuery';
import toast, { Toaster } from 'react-hot-toast';
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../apiService';
import { useQueryClient } from '@tanstack/react-query';
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
<div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}>
<div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
</div>
<div className={styles.mobileResultInfo}>
{partido.nombreCandidato ? (
<>
<span className={styles.mobileResultPartyName}>{partido.nombreCandidato}</span>
<span className={styles.mobileResultCandidateName}>{partido.nombreCorto || partido.nombre}</span>
</>
) : (
<span className={styles.mobileResultPartyName}>{partido.nombreCorto || partido.nombre}</span>
)}
</div>
<div className={styles.mobileResultStats}>
<strong>{formatPercent(partido.porcentaje)}</strong>
<span>{partido.votos.toLocaleString('es-AR')}</span>
</div>
</div>
);
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
interface MobileResultsCardProps {
eleccionId: number;
ambitoId: string | null;
categoriaId: number;
ambitoNombre: string;
ambitoNivel: 'pais' | 'provincia' | 'municipio';
mobileView: 'mapa' | 'resultados';
setMobileView: (view: 'mapa' | 'resultados') => void;
}
const MobileResultsCard = ({
eleccionId, ambitoId, categoriaId, ambitoNombre, ambitoNivel, mobileView, setMobileView
}: MobileResultsCardProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { data } = useSuspenseQuery<PanelElectoralDto>({
queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId, ambitoNivel],
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId, ambitoNivel),
refetchInterval: 180000,
});
useEffect(() => {
setIsExpanded(ambitoNivel === 'municipio');
}, [ambitoNivel]);
const topResults = data.resultadosPanel.slice(0, 3);
if (topResults.length === 0 && ambitoNivel === 'pais') {
return null;
}
const cardClasses = [
styles.mobileResultsCardContainer,
isExpanded ? styles.expanded : '',
styles[`view-${mobileView}`]
].join(' ');
return (
<div className={cardClasses}>
<div className={styles.collapsibleSection}>
<div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}>
<div className={styles.headerInfo}>
<h4>{ambitoNombre}</h4>
<span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
</div>
<div className={styles.headerToggleIcon}>
{isExpanded ? <FiChevronDown /> : <FiChevronUp />}
</div>
</div>
<div className={styles.mobileResultsContent}>
{topResults.length > 0 ? (
topResults.map(partido => <ResultRow key={partido.id} partido={partido} />)
) : (
<p className={styles.noResultsText}>No hay resultados para esta selección.</p>
)}
</div>
</div>
<div className={styles.mobileCardViewToggle}>
<button
className={`${styles.toggleBtn} ${mobileView === 'mapa' ? styles.active : ''}`}
onClick={() => setMobileView('mapa')}
>
<FiMap />
<span>Mapa</span>
</button>
<button
className={`${styles.toggleBtn} ${mobileView === 'resultados' ? styles.active : ''}`}
onClick={() => setMobileView('resultados')}
>
<FiList />
<span>Detalles</span>
</button>
</div>
</div>
);
};
// --- WIDGET PRINCIPAL ---
interface PanelNacionalWidgetProps {
eleccionId: number;
}
type AmbitoState = {
id: string | null;
nivel: 'pais' | 'provincia' | 'municipio';
nombre: string;
provinciaNombre?: string;
provinciaDistritoId?: string | null;
};
const CATEGORIAS_NACIONALES = [
{ value: 3, label: 'Diputados Nacionales' },
{ value: 2, label: 'Senadores Nacionales' },
];
const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => {
const { data } = useSuspenseQuery<PanelElectoralDto>({
queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel],
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel),
refetchInterval: 180000,
});
if (data.sinDatos) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
<h4>Sin Resultados Detallados</h4>
<p>Aún no hay datos disponibles para esta selección.</p>
<p>Por favor, intente de nuevo más tarde.</p>
</div>
);
}
return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />;
};
export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => {
const queryClient = useQueryClient();
const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
const [categoriaId, setCategoriaId] = useState<number>(3);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
const isMobile = useMediaQuery('(max-width: 800px)');
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
if (nuevoNivel === 'municipio') {
toast.promise(
queryClient.invalidateQueries({ queryKey: ['panelElectoral', eleccionId, nuevoAmbitoId, categoriaId, nuevoNivel] }),
{
loading: `Cargando datos de ${nuevoNombre}...`,
error: <b>No se pudieron cargar los datos.</b>,
}
);
}
setAmbitoActual(prev => ({
id: nuevoAmbitoId,
nivel: nuevoNivel,
nombre: nuevoNombre,
provinciaNombre: nuevoNivel === 'municipio' ? prev.provinciaNombre : (nuevoNivel === 'provincia' ? nuevoNombre : undefined),
provinciaDistritoId: nuevoNivel === 'provincia' ? nuevoAmbitoId : prev.provinciaDistritoId
}));
};
const handleResetToPais = () => {
setAmbitoActual({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
};
const handleVolverAProvincia = () => {
if (ambitoActual.provinciaDistritoId && ambitoActual.provinciaNombre) {
setAmbitoActual({
id: ambitoActual.provinciaDistritoId,
nivel: 'provincia',
nombre: ambitoActual.provinciaNombre,
provinciaDistritoId: ambitoActual.provinciaDistritoId,
provinciaNombre: ambitoActual.provinciaNombre,
});
} else {
handleResetToPais();
}
};
const selectedCategoria = useMemo(() =>
CATEGORIAS_NACIONALES.find(c => c.value === categoriaId),
[categoriaId]
);
const mainContentClasses = [
styles.panelMainContent,
!isPanelOpen ? styles.panelCollapsed : '',
isMobile ? styles[`mobile-view-${mobileView}`] : ''
].join(' ');
return (
<div className={styles.panelNacionalContainer}>
<Toaster
position="bottom-center"
containerClassName={styles.widgetToasterContainer}
/>
<header className={styles.panelHeader}>
<div className={styles.headerTopRow}>
<Select
options={CATEGORIAS_NACIONALES}
value={selectedCategoria}
onChange={(option) => option && setCategoriaId(option.value)}
classNamePrefix="categoriaSelector"
className={styles.categoriaSelectorContainer}
isSearchable={false}
/>
</div>
<div className={styles.headerBottomRow}>
<Breadcrumbs
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvincia={ambitoActual.provinciaNombre}
onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia}
/>
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
<MunicipioSearch
distritoId={ambitoActual.provinciaDistritoId}
onMunicipioSelect={(municipioId, municipioNombre) =>
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
}
/>
)}
</div>
</header>
<main className={mainContentClasses}>
<div className={styles.mapaColumn}>
<button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '' : ''} </button>
<Suspense fallback={<div className={styles.spinner} />}>
<MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} />
</Suspense>
</div>
<div className={styles.resultadosColumn}>
<Suspense fallback={<div className={styles.spinner} />}>
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
</Suspense>
</div>
<Suspense fallback={null}>
{isMobile && (
<MobileResultsCard
eleccionId={eleccionId}
ambitoId={ambitoActual.id}
categoriaId={categoriaId}
ambitoNombre={ambitoActual.nombre}
ambitoNivel={ambitoActual.nivel}
mobileView={mobileView}
setMobileView={setMobileView}
/>
)}
</Suspense>
</main>
</div>
);
};

View File

@@ -0,0 +1,299 @@
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.module.css */
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
.cardsWidgetContainer,
.cardsWidgetContainer * {
font-family: "Roboto", system-ui, sans-serif !important;
box-sizing: border-box;
}
/* --- Contenedor Principal del Widget y Variables --- */
.cardsWidgetContainer {
--card-border-color: #e0e0e0;
--card-bg-color: #ffffff;
--card-header-bg-color: #e6f1fd;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--text-primary: #212529;
--text-secondary: #6c757d;
--primary-accent-color: #007bff;
width: 100%;
max-width: 1200px;
margin: 2rem auto;
}
.cardsWidgetContainer h2 {
font-size: 1.75rem;
color: var(--text-primary);
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--card-border-color);
}
/* --- Grilla de Tarjetas --- */
.cardsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem;
align-items: start;
}
/* --- Tarjeta Individual --- */
.provinciaCard {
background-color: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 8px;
box-shadow: var(--card-shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- Cabecera de la Tarjeta --- */
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--card-header-bg-color);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--card-border-color);
}
.headerInfo h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
}
.headerInfo span {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.headerMap {
width: 90px;
height: 90px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
background-color: #f7fbff;
padding: 0.25rem;
}
.mapSvgContainer, .mapPlaceholder {
width: 100%;
height: 100%;
border-radius: 16px;
}
.mapSvgContainer svg {
width: 100%;
height: 100%;
object-fit: contain;
}
.mapPlaceholder.error {
background-color: #f8d7da;
}
/* --- Cuerpo de la Tarjeta --- */
.cardBody {
padding: 0.5rem 1rem;
}
.candidatoRow:last-child {
border-bottom: none;
}
.candidatoRow {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 0.75rem;
}
.candidatoFotoWrapper {
width: 60px;
height: 60px;
border-radius: 12px;
flex-shrink: 0;
background-color: #e9ecef;
}
.candidatoFoto {
width: 100%;
height: 100%;
border-radius: 12px;
}
.candidatoData {
flex-grow: 1;
min-width: 0;
margin-right: 0.5rem;
}
.candidatoNombre {
font-weight: 700;
font-size: 0.95rem;
color: var(--text-primary);
display: block;
text-align: left;
}
.candidatoPartido {
font-size: 0.75rem;
color: var(--text-secondary);
display: block;
margin-bottom: 0.3rem;
text-align: left;
}
.progressBarContainer {
height: 16px;
background-color: #e9ecef;
border-radius: 3px;
overflow: hidden;
}
.progressBar {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease-out;
}
.candidatoStats {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
padding-left: 0.5rem;
}
.statsPercent {
font-weight: 700;
font-size: 1.1rem;
color: var(--text-primary);
}
.statsVotos {
font-size: 0.8rem;
color: var(--text-secondary);
}
.statsBancas {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid var(--card-border-color);
border-radius: 6px;
padding: 0.25rem 0.5rem;
margin-left: 0.75rem;
font-weight: 700;
font-size: 1.2rem;
color: var(--primary-accent-color);
min-width: 50px;
}
.statsBancas span {
font-size: 0.65rem;
font-weight: 500;
color: var(--text-secondary);
margin-top: -4px;
}
/* --- Pie de la Tarjeta --- */
.cardFooter {
display: grid;
grid-template-columns: repeat(3, 1fr);
background-color: var(--card-header-bg-color);
border-top: 1px solid var(--card-border-color);
padding: 0.75rem 0;
text-align: center;
}
.cardFooter div {
border-right: 1px solid var(--card-border-color);
}
.cardFooter div:last-child {
border-right: none;
}
.cardFooter span {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
}
.cardFooter strong {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
}
/* --- Media Query para Móvil --- */
@media (max-width: 480px) {
.cardsGrid {
grid-template-columns: 1fr;
}
.cardHeader {
padding: 0.5rem;
}
.headerInfo h3 {
font-size: 1rem;
}
}
.candidatoPartido.mainTitle {
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
text-transform: none;
margin-bottom: 0.3rem;
}
/* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */
.categoriaBloque {
width: 100%;
}
.categoriaBloque + .categoriaBloque {
border-top: 1px dashed var(--card-border-color);
margin-top: 1rem;
padding-top: 1rem;
}
.categoriaTitulo {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
text-align: center;
margin: 0 0 1rem 0;
}
.categoriaBloque .cardFooter {
grid-template-columns: repeat(3, 1fr);
background-color: transparent;
border-top: 1px solid var(--card-border-color);
padding: 0.75rem 0;
margin-top: 0.75rem;
text-align: center;
}
.categoriaBloque .cardFooter div {
border-right: 1px solid var(--card-border-color);
}
.categoriaBloque .cardFooter div:last-child {
border-right: none;
}

View File

@@ -0,0 +1,51 @@
// src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.tsx
import { useQuery } from '@tanstack/react-query';
import { getResumenPorProvincia } from '../../../apiService';
import { ProvinciaCard } from './components/ProvinciaCard';
import styles from './ResultadosNacionalesCardsWidget.module.css';
interface Props {
eleccionId: number;
focoDistritoId?: string;
focoCategoriaId?: number;
cantidadResultados?: number;
mostrarBancas?: boolean;
}
export const ResultadosNacionalesCardsWidget = ({
eleccionId,
focoDistritoId,
focoCategoriaId,
cantidadResultados,
mostrarBancas = false
}: Props) => {
const { data, isLoading, error } = useQuery({
queryKey: ['resumenPorProvincia', eleccionId, focoDistritoId, focoCategoriaId, cantidadResultados],
queryFn: () => getResumenPorProvincia(eleccionId, {
focoDistritoId,
focoCategoriaId,
cantidadResultados
}),
refetchInterval: 180000,
});
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={styles.cardsWidgetContainer}>
<div className={styles.cardsGrid}>
{data?.map(provinciaData => (
<ProvinciaCard
key={provinciaData.provinciaId}
data={provinciaData}
mostrarBancas={mostrarBancas}
/>
))}
</div>
</section>
);
};

View File

@@ -0,0 +1,107 @@
/* src/components/widgets/ResumenNacionalWidget.module.css */
.widgetContainer {
font-family: 'Roboto', sans-serif;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
margin: 2rem auto;
}
.header {
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: 230px;
}
.resultsTable {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
.resultsTable thead {
display: none;
}
.resultsTable td {
padding: 3px 8px;
text-align: left;
}
.provinciaBlock {
border-top: 1px solid #e0e0e0;
}
.provinciaBlock:first-child {
border-top: none;
}
.provinciaNombre {
font-weight: 700;
font-size: 1rem;
text-transform: uppercase;
color: #333;
padding-top: 1rem;
}
.provinciaEscrutado {
font-size: 0.8rem;
color: #666;
text-align: right;
white-space: nowrap;
padding-top: 1rem;
width: 1%;
}
.partidoNombre {
font-size: 0.9rem;
}
.partidoPorcentaje {
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 */
}
}

View File

@@ -0,0 +1,105 @@
// src/components/widgets/ResumenNacionalWidget.tsx
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import Select from 'react-select';
import { getResumenNacionalPorProvincia } from '../../../apiService';
import styles from './ResumenNacionalWidget.module.css';
const ELECCION_ID = 2; // Exclusivo para elecciones nacionales
const CATEGORIAS_NACIONALES = [
{ value: 3, label: 'Diputados 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]);
const { data, isLoading, error } = useQuery({
queryKey: ['resumenNacional', ELECCION_ID, categoria.value],
queryFn: () => getResumenNacionalPorProvincia(ELECCION_ID, categoria.value),
refetchInterval: 180000,
});
// 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.subHeader}>
<h4>{categoria.label}</h4>
<Select
className={styles.categoriaSelector}
options={CATEGORIAS_NACIONALES}
value={categoria}
onChange={(opt) => setCategoria(opt!)}
isSearchable={false}
/>
</div>
{isLoading && <p>Cargando resumen nacional...</p>}
{error && <p style={{ color: 'red' }}>Error al cargar los datos.</p>}
{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>
))}
</table>
)}
</div>
);
};

View File

@@ -0,0 +1,52 @@
// src/features/legislativas/nacionales/TablaConurbanoWidget.tsx
import { useQuery } from '@tanstack/react-query';
import { getTablaConurbano } from '../../../apiService';
import styles from './TablaResultadosWidget.module.css';
export const TablaConurbanoWidget = () => {
const ELECCION_ID = 2;
const { data, isLoading, error } = useQuery({
queryKey: ['tablaConurbano', ELECCION_ID],
queryFn: () => getTablaConurbano(ELECCION_ID),
refetchInterval: 180000,
});
const formatPercent = (num: number) => `${num.toFixed(2)}%`;
return (
<div className={styles.widgetContainer}>
<div className={styles.header}>
<h3>Diputados Nacionales</h3>
</div>
{isLoading && <p>Cargando resultados...</p>}
{error && <p>Error al cargar los datos.</p>}
{data && (
<table className={styles.resultsTable}>
<thead>
<tr>
<th>Distrito</th>
<th>1ra Fuerza</th>
<th>%</th>
<th>2da Fuerza</th>
<th>%</th>
</tr>
</thead>
<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.fuerzaCell}>{fila.fuerza1Display}</td>
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};

View File

@@ -0,0 +1,176 @@
/* src/features/legislativas/nacionales/TablaResultadosWidget.module.css */
.widgetContainer {
font-family: 'Roboto', sans-serif;
max-width: 900px;
margin: 2rem auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e0e0e0;
}
.header h3 {
margin: 0;
font-size: 1.5rem;
}
.categoriaSelector {
min-width: 250px;
}
.resultsTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.resultsTable th,
.resultsTable td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.resultsTable th {
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
color: #6c757d;
}
.distritoCell {
font-weight: 500;
}
.fuerzaCell {
color: #212529;
}
.porcentajeCell {
font-weight: 700;
text-align: right;
min-width: 80px;
}
.seccionHeader td {
background-color: #f8f9fa;
font-weight: 700;
font-size: 1.1rem;
color: #007bff;
border-top: 2px solid #007bff;
border-bottom: 2px solid #007bff;
}
.distritoIndex {
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;
}
}

View File

@@ -0,0 +1,60 @@
// src/features/legislativas/nacionales/TablaSeccionesWidget.tsx
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { getTablaSecciones } from '../../../apiService';
import styles from './TablaResultadosWidget.module.css';
export const TablaSeccionesWidget = () => {
const ELECCION_ID = 2;
const { data, isLoading, error } = useQuery({
queryKey: ['tablaSecciones', ELECCION_ID],
queryFn: () => getTablaSecciones(ELECCION_ID),
refetchInterval: 180000,
});
const formatPercent = (num: number) => `${num.toFixed(2)}%`;
return (
<div className={styles.widgetContainer}>
<div className={styles.header}>
<h3>Diputados Nacionales</h3>
</div>
{isLoading && <p>Cargando resultados...</p>}
{error && <p>Error al cargar los datos.</p>}
{data && (
<table className={styles.resultsTable}>
<thead>
<tr>
<th>Municipio</th>
<th>1ra Fuerza</th>
<th>%</th>
<th>2da Fuerza</th>
<th>%</th>
</tr>
</thead>
<tbody>
{data.map((seccion) => (
<React.Fragment key={seccion.seccionId}>
<tr className={styles.seccionHeader}>
<td colSpan={5}>{seccion.nombre}</td>
</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.fuerzaCell}>{fila.fuerza1Display}</td>
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
// src/features/legislativas/nacionales/components/AnimatedNumber.tsx
import { useAnimatedNumber } from '../hooks/useAnimatedNumber';
interface AnimatedNumberProps {
value: number;
formatter: (value: number) => string;
}
export const AnimatedNumber = ({ value, formatter }: AnimatedNumberProps) => {
const animatedValue = useAnimatedNumber(value);
return <span>{formatter(animatedValue)}</span>;
};

Some files were not shown because too many files have changed in this diff Show More