Compare commits

...

78 Commits

Author SHA1 Message Date
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
64dc7ef440 Pre Nacionales 2025-09-11 10:16:45 -03:00
843c0f7258 Fix Cache 2025-09-10 15:08:14 -03:00
326b6b3c59 Fix Boostrap 2025-09-10 14:58:29 -03:00
143759a929 Retry 1449 2025-09-10 14:49:06 -03:00
153c0f92da Fix Boostrap y Try Cache 2025-09-10 14:20:44 -03:00
6309003536 Fix mjs 2025-09-09 09:54:50 -03:00
f4d4cd173f Fix Tabla Columnas 2025-09-08 15:23:18 -03:00
2fac830528 Fix TimeOut Telegramas 1425 2025-09-08 14:25:19 -03:00
d091d91f89 Try TimeOut Telegramas 2025-09-08 14:19:16 -03:00
9e0cf30294 Feat Selector Modo Tabla 2025-09-08 14:11:05 -03:00
d5168e1d17 Fix Mapa Css 1320 2025-09-08 13:20:50 -03:00
2169b57bc6 Fix Mapas Css 2025-09-08 13:09:30 -03:00
865cc488df Fix Mapa Css 2025-09-08 12:53:38 -03:00
70c8ce54de Feat Telegramas Busquedas 2025-09-08 12:17:22 -03:00
c105106f3b Test Lote 5 Telegramas Worker 2025-09-08 10:53:53 -03:00
f497c89ffa Fix Woker Lote Telegramas 20 2025-09-08 10:39:55 -03:00
565128321c Fix Telegramas Worker 2025-09-08 10:22:51 -03:00
2b47d8e20d Fix Worker 2025-09-07 23:04:36 -03:00
fc97e29f13 Fix Mapa 2025-09-07 21:44:22 -03:00
7f49362e55 Fix Selectores 2025-09-07 20:12:03 -03:00
50d3c6bce9 Fix DevApp 2025-09-07 19:58:34 -03:00
ad30d4263d Sin filtro de Municipios Válidos SenadoresWidget DiputadosWidget 2025-09-07 19:53:59 -03:00
a49fc80fd9 Fix Worker Serilog 2025-09-06 22:13:09 -03:00
475b886d9a Fix Nivel de Ejecución 2025-09-06 21:56:29 -03:00
fa92d9638c Feat Workers Prioridades y Nivel Serilog 2025-09-06 21:44:52 -03:00
f384a640f3 Fix Columna Tabla 2025-09-05 15:52:55 -03:00
31d434b2aa Fix Columna Faltante 2025-09-05 14:53:14 -03:00
1d9ed05446 Fix ZIndex Dropdown 2025-09-05 14:10:37 -03:00
d52c452009 Fix Bold Nombre Partido Político (Tickers) 2025-09-05 14:04:38 -03:00
6354791f28 Fix Css Ticker 2025-09-05 13:56:09 -03:00
a495ab67ef Add domain cloud 2025-09-05 13:40:33 -03:00
c48cc1bec5 Fix Intervalos de Refetch 2025-09-05 13:33:09 -03:00
12acd61f2b Fix Overrides Candidatos 2025-09-05 12:58:52 -03:00
d78a02a0eb Feat Widgets
Se añade la tabla CandidatosOverrides
Se añade el Overrides de Candidatos al panel de administrador
Se Añade el nombre de los candidatos a los Widgets de categorias por municipio
2025-09-05 11:38:25 -03:00
479c2c60f2 Fix Camaras Layouts assetBaseUrl 2025-09-04 17:39:20 -03:00
0ce5e2e2c9 Fix API_BASE_URL 2025-09-04 17:19:54 -03:00
2db20969a1 FEat Widgets Tablas 2025-09-04 15:54:00 -03:00
f41b4eaa1c Feat Widget Tabla de Resultados Por Seccion 2025-09-04 14:35:12 -03:00
ff6c2f29e7 Disabled Worker 2025-09-04 11:29:17 -03:00
5d0f2460f9 Feat BancasWidget 2025-09-04 11:27:12 -03:00
8ce48b3a46 Fix Cors 2025-09-03 18:56:01 -03:00
29f8146b32 Fix Url Bases Prod Des 2025-09-03 18:49:55 -03:00
83047721a3 Fix Img Url 2025-09-03 18:36:54 -03:00
c23e6f136e Clean .json 2025-09-03 18:15:20 -03:00
fdeacda683 Test Subdominio 2025-09-03 18:02:13 -03:00
ef3967fcd6 Fix 1754 2025-09-03 17:54:49 -03:00
cb329da04e Fix 1738 2025-09-03 17:38:05 -03:00
a5638e3e91 Fix 1715 2025-09-03 17:16:40 -03:00
46b2d2cfe6 Fix 1705 2025-09-03 17:06:39 -03:00
a7e231dfb6 Fix 1700 2025-09-03 16:58:27 -03:00
25def576e9 Fix 1600 2025-09-03 16:01:52 -03:00
025f051839 Fix 1558 2025-09-03 15:58:34 -03:00
82371bd159 Fix 1545 2025-09-03 15:45:49 -03:00
eee21b8d52 Fix 1540 2025-09-03 15:40:23 -03:00
675c1f83a5 Fix 1531 2025-09-03 15:31:25 -03:00
6ebe62ef87 Fix 1523 2025-09-03 15:23:32 -03:00
cf571cbc14 Fix Proxy 1511 2025-09-03 15:12:00 -03:00
7bc96fec2b Fix 1503 2025-09-03 15:03:15 -03:00
0c1cca64a8 Fix 1501 2025-09-03 15:01:09 -03:00
3381dd1778 Fix 1455 2025-09-03 14:55:19 -03:00
57fe26afa9 Fix 1435 2025-09-03 14:35:18 -03:00
6a508f468b Fix 1430 2025-09-03 14:30:20 -03:00
18439c40d7 Fix 1421 2025-09-03 14:21:31 -03:00
b8e6d33afa Fix 1416 2025-09-03 14:16:18 -03:00
36a004a0b0 Fix Nginx y Boostrap 2025-09-03 14:05:06 -03:00
a81f1fe894 Test Public Side 2025-09-03 13:49:35 -03:00
32e85b9b9d Feat Widgets 2030 2025-09-02 20:34:49 -03:00
6732a0e826 Feat Widgets 1930 2025-09-02 19:38:04 -03:00
9393d2bc05 Feat Widgets Tickers 2025-09-02 17:08:56 -03:00
6ac6034255 Fix Controller 2025-09-02 15:43:27 -03:00
202 changed files with 16583 additions and 1825 deletions

View File

@@ -0,0 +1,18 @@
# frontend-admin/Dockerfile
# --- Etapa 1: Build (Construcción) ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Etapa 2: Serve (Servir con Nginx configurado para SPA) ---
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Copiamos nuestra configuración de Nginx para manejar el enrutamiento de React
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,18 @@
# frontend-admin/frontend.nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public";
}
}

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Elecciones 2025 - El Día</title>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

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,143 +1,144 @@
// 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 './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)' }
];
const sanitizeColor = (color: string | null | undefined): string => {
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()),
});
// Usamos useEffect para reaccionar cuando los datos de 'logos' se cargan o cambian.
useEffect(() => {
if (logos) {
setEditedLogos(logos);
}
}, [logos]);
// Usamos otro useEffect para reaccionar a los datos de 'agrupaciones'.
useEffect(() => {
if (agrupaciones) {
const initialEdits = Object.fromEntries(agrupaciones.map(a => [a.id, {}]));
if (agrupaciones.length > 0) {
const initialEdits = Object.fromEntries(
agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }])
);
setEditedAgrupaciones(initialEdits);
}
}, [agrupaciones]);
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => {
setEditedAgrupaciones(prev => ({
...prev,
[id]: { ...prev[id], [field]: value }
}));
};
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 (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]);
if (existing) {
existing.logoUrl = value;
} else {
newLogos.push({
id: 0,
agrupacionPoliticaId: agrupacionId,
categoriaId,
logoUrl: value,
ambitoGeograficoId: null
});
}
return newLogos;
});
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);
});
// --- 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(editedLogos);
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" value={editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color ?? '#000000'} 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.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>

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.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.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,16 +84,7 @@ 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 || ''}
@@ -102,11 +96,7 @@ export const BancasManager = () => {
</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

@@ -0,0 +1,106 @@
// src/components/CandidatoOverridesManager.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Select from 'react-select';
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 ELECCION_OPTIONS = [
{ 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 [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 [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
const [nombreCandidato, setNombreCandidato] = useState('');
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: candidatos = [] } = useQuery<CandidatoOverride[]>({
queryKey: ['candidatos', selectedEleccion.value],
queryFn: () => getCandidatos(selectedEleccion.value),
});
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
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 '';
const ambitoId = getAmbitoId();
return candidatos.find(c =>
c.ambitoGeograficoId === ambitoId &&
c.agrupacionPoliticaId === selectedAgrupacion.id &&
c.categoriaId === selectedCategoria.value
)?.nombreCandidato || '';
}, [candidatos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]);
const handleSave = async () => {
if (!selectedAgrupacion || !selectedCategoria) return;
const newCandidatoEntry: CandidatoOverride = {
id: 0,
eleccionId: selectedEleccion.value,
agrupacionPoliticaId: selectedAgrupacion.id,
categoriaId: selectedCategoria.value,
ambitoGeograficoId: getAmbitoId(),
nombreCandidato: nombreCandidato || null
};
try {
await updateCandidatos([newCandidatoEntry]);
queryClient.invalidateQueries({ queryKey: ['candidatos', selectedEleccion.value] });
alert('Override de candidato guardado.');
} catch (error) {
console.error(error);
alert('Error al guardar el override del candidato.');
}
};
return (
<div className="admin-module">
<h3>Overrides de Nombres de Candidatos</h3>
<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..." isDisabled={!selectedEleccion} />
<Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => 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>Nombre del Candidato</label>
<input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} />
</div>
<button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,110 @@
// 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 className="form-group">
<label>
<input type="checkbox" checked={modoOficialActivo} onChange={e => setModoOficialActivo(e.target.checked)} />
**Activar Modo "Resultados Oficiales" para Widgets Nacionales**
</label>
<p style={{ fontSize: '0.8rem', color: '#666' }}>
Si está activo, los widgets nacionales usarán la composición manual de bancas. Si no, usarán la proyección en tiempo real.
</p>
</div>*/}
<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.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.nombre}</option>))}
</select>
</div>
</div>
<button onClick={handleSave} style={{ marginTop: '1.5rem' }}>
Guardar Configuración
</button>
</div>
);
};

View File

@@ -1,34 +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 />
<LogoOverridesManager />
<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>
<main style={{ marginTop: '2rem' }}>
<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 />
</main>
</div>
);

View File

@@ -2,81 +2,105 @@
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 (Toda la elección)' },
{ 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 ---
// --- ESTADOS ---
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(() => municipios.map(m => ({ value: m.id, label: m.nombre })), [municipios]);
const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]);
// --- QUERIES ---
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: ['logos', selectedEleccion.value],
queryFn: () => getLogos(selectedEleccion.value)
});
// --- LÓGICA DE SELECTORES DINÁMICOS ---
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return 0;
};
const currentLogo = useMemo(() => {
// La búsqueda ahora depende de los 3 selectores
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return '';
return logos.find(l =>
l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&
l.agrupacionPoliticaId === selectedAgrupacion.value &&
if (!selectedAgrupacion || !selectedCategoria) return '';
const ambitoId = getAmbitoId();
return logos.find(l =>
l.ambitoGeograficoId === ambitoId &&
l.agrupacionPoliticaId === selectedAgrupacion.id &&
l.categoriaId === selectedCategoria.value
)?.logoUrl || '';
}, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]);
}, [logos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
const handleSave = async () => {
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
if (!selectedAgrupacion || !selectedCategoria) return;
const newLogoEntry: LogoAgrupacionCategoria = {
id: 0,
agrupacionPoliticaId: selectedAgrupacion.value,
eleccionId: selectedEleccion.value,
agrupacionPoliticaId: selectedAgrupacion.id,
categoriaId: selectedCategoria.value,
ambitoGeograficoId: parseInt(selectedMunicipio.value),
ambitoGeograficoId: getAmbitoId(),
logoUrl: logoUrl || null
};
try {
await updateLogos([newLogoEntry]);
queryClient.invalidateQueries({ queryKey: ['logos'] });
queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] });
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..." isDisabled={!selectedEleccion} />
<Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => 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

@@ -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.nombreCorto || agrupacion.nombre}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</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.nombreCorto || agrupacion.nombre}
</SortableItem>
))}
</ul>
</SortableContext>
</DndContext>
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
</div>
);
};

View File

@@ -0,0 +1,140 @@
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getConfiguracion, updateConfiguracion, updateLoggingLevel } from '../services/apiService';
import type { ConfiguracionResponse } from '../services/apiService';
// --- Componente de Switch reutilizable para la UI ---
const Switch = ({ label, isChecked, onChange }: { label: string, isChecked: boolean, onChange: (checked: boolean) => void }) => (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}>
<input type="checkbox" checked={isChecked} onChange={e => onChange(e.target.checked)} />
{label}
</label>
);
export const WorkerManager = () => {
const queryClient = useQueryClient();
// Estados locales para manejar los valores de la UI
const [resultadosActivado, setResultadosActivado] = useState(true);
const [bajasActivado, setBajasActivado] = useState(true);
const [prioridad, setPrioridad] = useState('Resultados');
const [loggingLevel, setLoggingLevel] = useState('Information');
// Query para obtener la configuración actual desde la API
const { data: configData, isLoading } = useQuery<ConfiguracionResponse>({
queryKey: ['configuracion'],
queryFn: getConfiguracion,
});
// useEffect para sincronizar el estado local con los datos de la API una vez cargados
useEffect(() => {
if (configData) {
setResultadosActivado(configData.Worker_Resultados_Activado === 'true');
setBajasActivado(configData.Worker_Bajas_Activado === 'true');
setPrioridad(configData.Worker_Prioridad || 'Resultados');
setLoggingLevel(configData.Logging_Level || 'Information');
}
}, [configData]);
const handleSave = async () => {
try {
// Creamos dos promesas separadas, una para la config general y otra para el logging
const configPromise = updateConfiguracion({
...configData,
'Worker_Resultados_Activado': resultadosActivado.toString(),
'Worker_Bajas_Activado': bajasActivado.toString(),
'Worker_Prioridad': prioridad,
'Logging_Level': loggingLevel,
});
// La llamada al endpoint de logging-level es la que cambia el nivel EN VIVO.
const loggingPromise = updateLoggingLevel({ level: loggingLevel });
// Ejecutamos ambas en paralelo
await Promise.all([configPromise, loggingPromise]);
queryClient.invalidateQueries({ queryKey: ['configuracion'] });
alert('Configuración de Workers y Logging guardada.');
} catch (error) {
console.error("Error al guardar la configuración:", error);
alert('Error al guardar la configuración.');
}
};
const isPrioridadDisabled = !resultadosActivado || !bajasActivado;
if (isLoading) {
return <div className="admin-module"><h3>Gestión de Workers</h3><p>Cargando configuración...</p></div>;
}
return (
<div className="admin-module">
<h3>Gestión de Workers</h3>
<p>Controla el comportamiento de los procesos de captura de datos.</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', borderTop: '1px solid #eee', paddingTop: '1rem' }}>
{/* --- Switches On/Off --- */}
<div style={{ display: 'flex', alignSelf: 'center', gap: '2rem' }}>
<Switch
label="Activar Worker de Resultados"
isChecked={resultadosActivado}
onChange={setResultadosActivado}
/>
<Switch
label="Activar Worker de Bancas/Telegramas"
isChecked={bajasActivado}
onChange={setBajasActivado}
/>
</div>
{/* --- Contenedor para Selectores --- */}
<div style={{ display: 'flex', gap: '2rem', alignSelf:'center', alignItems: 'flex-start' }}>
{/* --- Selector de Prioridad --- */}
<div>
<label htmlFor="prioridad-select" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>
Prioridad (si ambos están activos)
</label>
<select
id="prioridad-select"
value={prioridad}
onChange={e => setPrioridad(e.target.value)}
disabled={isPrioridadDisabled}
style={{ padding: '0.5rem', minWidth: '200px' }}
>
<option value="Resultados">Resultados (Noche Electoral)</option>
<option value="Telegramas">Telegramas (Post-Escrutinio)</option>
</select>
{isPrioridadDisabled && <small style={{ display: 'block', marginTop: '0.5rem', color: '#666' }}>Activar ambos workers para elegir prioridad.</small>}
</div>
{/* --- NUEVO: Selector de Nivel de Logging --- */}
<div>
<label htmlFor="logging-select" style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 500 }}>
Nivel de Logging (En vivo)
</label>
<select
id="logging-select"
value={loggingLevel}
onChange={e => setLoggingLevel(e.target.value)}
style={{ padding: '0.5rem', minWidth: '200px' }}
>
<option value="Verbose">Verbose (Máximo detalle)</option>
<option value="Debug">Debug</option>
<option value="Information">Information (Normal)</option>
<option value="Warning">Warning (Advertencias)</option>
<option value="Error">Error</option>
<option value="Fatal">Fatal (Críticos)</option>
</select>
<small style={{ display: 'block', marginTop: '0.5rem', color: '#666' }}>Cambia el nivel de log en tiempo real.</small>
</div>
</div>
{/* --- Botón de Guardar --- */}
<div style={{ marginTop: '1rem' }}>
<button onClick={handleSave}>Guardar Toda la Configuración</button>
</div>
</div>
</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: 1, label: 'Senadores Nacionales' },
{ value: 2, label: 'Diputados Nacionales' },
// Provinciales
{ value: 5, label: 'Senadores Provinciales' },
{ value: 6, label: 'Diputados Provinciales' },
{ value: 7, label: 'Concejales' },
];
export const CATEGORIAS_NACIONALES_OPTIONS = [
{ value: 1, label: 'Senadores Nacionales' },
{ value: 2, 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,18 +1,40 @@
// src/services/apiService.ts
import axios from 'axios';
import { triggerLogout } from '../context/authUtils';
import type { AgrupacionPolitica, UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, MunicipioSimple } from '../types';
import type { CandidatoOverride, AgrupacionPolitica,
UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria,
MunicipioSimple, BancaPrevia, ProvinciaSimple } from '../types';
const AUTH_API_URL = 'http://localhost:5217/api/auth';
const ADMIN_API_URL = 'http://localhost:5217/api/admin';
/**
* URL base para las llamadas a la API.
*/
const API_URL_BASE = import.meta.env.DEV
? 'http://localhost:5217/api'
: 'https://elecciones2025.eldia.com/api';
/**
* URL completa para el endpoint de autenticación.
*/
export const AUTH_API_URL = `${API_URL_BASE}/auth`;
/**
* URL completa para los endpoints de administración.
*/
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');
@@ -24,7 +46,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) => {
@@ -36,6 +57,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
@@ -51,7 +98,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;
@@ -62,14 +109,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;
};
@@ -96,18 +143,52 @@ 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 getCandidatos = async (eleccionId: number): Promise<CandidatoOverride[]> => {
const response = await adminApiClient.get(`/candidatos?eleccionId=${eleccionId}`);
return response.data;
};
export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => {
await adminApiClient.put('/candidatos', data);
};
// 7. Catálogos
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');
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> => {
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;
};

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,12 +42,35 @@ 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 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;
nombreCandidato: string | null;
}

View File

@@ -1,34 +1,20 @@
# --- Etapa 1: Build (Construcción) ---
# Usamos una imagen de Node.js para instalar dependencias y construir la aplicación de React.
#Dockerfile
# --- Etapa 1: Build ---
FROM node:20-alpine AS build
# Establecemos el directorio de trabajo dentro del contenedor.
WORKDIR /app
# Copiamos los archivos de manifiesto del proyecto.
COPY package.json ./
COPY package-lock.json ./
# Instalamos las dependencias.
COPY package*.json ./
RUN npm install
# Copiamos el resto del código fuente de la aplicación.
COPY . .
# Ejecutamos el script de construcción de Vite para generar los archivos estáticos.
RUN npm run build
# --- Etapa 2: Serve (Servir) ---
# Usamos una imagen de Nginx, que es un servidor web muy ligero y eficiente.
# Es ideal para servir archivos estáticos (HTML, CSS, JS).
# --- Etapa 2: Producción ---
FROM nginx:1.25-alpine
# Copiamos los archivos de producción construidos en la etapa anterior
# al directorio por defecto donde Nginx sirve los archivos.
COPY --from=build /app/dist /usr/share/nginx/html
# Exponemos el puerto 80 (el puerto por defecto de Nginx).
EXPOSE 80
# Copia la configuración de Nginx al contenedor del frontend
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
# El comando por defecto de la imagen de Nginx ya es iniciar el servidor,
# así que no necesitamos un CMD o ENTRYPOINT.
EXPOSE 80
# El CMD es opcional ya que la imagen base lo tiene, pero no hace daño
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,3 +1,4 @@
//eslint.config.js
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'

View File

@@ -0,0 +1,30 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) ---
location = /bootstrap.js {
# 1. Aseguramos que Nginx genere la huella digital ETag.
etag on;
# 2. Instrucciones explícitas de no cachear.
expires -1; # Equivalente a 'off', pero a veces más fuerte.
add_header Cache-Control "no-cache, must-revalidate, private";
try_files $uri =404;
}
# Bloque para activos con hash (sin cambios, ya es correcto)
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public";
try_files $uri =404;
}
# Bloque para la SPA (sin cambios)
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Elecciones 2025 - Dev Showcase</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,27 @@
},
"dependencies": {
"@nivo/bar": "^0.99.0",
"@nivo/waffle": "^0.99.0",
"@tanstack/react-query": "^5.85.5",
"@types/d3-geo": "^3.1.0",
"@types/d3-shape": "^3.1.7",
"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"
"react-tooltip": "^5.29.1",
"swiper": "^12.0.2",
"topojson-client": "^3.1.0",
"vite-plugin-svgr": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
@@ -30,6 +39,7 @@
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/react-select": "^5.0.0",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",

View File

@@ -0,0 +1,88 @@
// frontend/public/bootstrap.js
(function () {
// El dominio donde se alojan los widgets
const WIDGETS_HOST = 'https://elecciones2025.eldia.com';
// Función para cargar dinámicamente un script
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'module';
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Función para cargar dinámicamente una hoja de estilos
function loadCSS(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// Función principal
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);
}
} catch (error) {
console.error('Error al inicializar los widgets de elecciones:', error);
}
}
if (document.readyState === 'loading') { // Aún cargando
document.addEventListener('DOMContentLoaded', initWidgets);
} else { // Ya cargado
initWidgets();
}
})();

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="89" height="69">
<path d="M0 0 C29.37 0 58.74 0 89 0 C89 22.77 89 45.54 89 69 C59.63 69 30.26 69 0 69 C0 46.23 0 23.46 0 0 Z " fill="#008FBD" transform="translate(0,0)"/>
<path d="M0 0 C3.3 0 6.6 0 10 0 C13.04822999 3.04822999 12.29337257 6.08805307 12.32226562 10.25390625 C12.31904297 11.32511719 12.31582031 12.39632812 12.3125 13.5 C12.32861328 14.57121094 12.34472656 15.64242187 12.36132812 16.74609375 C12.36197266 17.76832031 12.36261719 18.79054688 12.36328125 19.84375 C12.36775269 21.25430664 12.36775269 21.25430664 12.37231445 22.69335938 C12.1880188 23.83514648 12.1880188 23.83514648 12 25 C9 27 9 27 0 27 C0 18.09 0 9.18 0 0 Z " fill="#000000" transform="translate(0,21)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 3.3 17 6.6 17 10 C25.58 10 34.16 10 43 10 C43 10.99 43 11.98 43 13 C34.42 13 25.84 13 17 13 C17 17.29 17 21.58 17 26 C11.39 26 5.78 26 0 26 C0 24.68 0 23.36 0 22 C4.62 22 9.24 22 14 22 C14 16.06 14 10.12 14 4 C9.38 4 4.76 4 0 4 C0 2.68 0 1.36 0 0 Z " fill="#000000" transform="translate(46,21)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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="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

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

@@ -5,19 +5,6 @@
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);

View File

@@ -3,23 +3,62 @@ import './App.css'
import { BancasWidget } from './components/BancasWidget'
import { CongresoWidget } from './components/CongresoWidget'
import MapaBsAs from './components/MapaBsAs'
import { TickerWidget } from './components/TickerWidget'
import { DipSenTickerWidget } from './components/DipSenTickerWidget'
import { TelegramaWidget } from './components/TelegramaWidget'
import { ConcejalesWidget } from './components/ConcejalesWidget'
import MapaBsAsSecciones from './components/MapaBsAsSecciones'
import { SenadoresWidget } from './components/SenadoresWidget'
import { DiputadosWidget } from './components/DiputadosWidget'
import { ResumenGeneralWidget } from './components/ResumenGeneralWidget'
import { SenadoresTickerWidget } from './components/SenadoresTickerWidget'
import { DiputadosTickerWidget } from './components/DiputadosTickerWidget'
import { ConcejalesTickerWidget } from './components/ConcejalesTickerWidget'
import { DiputadosPorSeccionWidget } from './components/DiputadosPorSeccionWidget'
import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidget'
import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget'
import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget'
import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget'
function App() {
return (
<>
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
<main>
<TickerWidget />
<main className="space-y-6">
<ResumenGeneralWidget />
<hr className="border-gray-300" />
<SenadoresTickerWidget />
<hr className="border-gray-300" />
<DiputadosTickerWidget />
<hr className="border-gray-300" />
<ConcejalesTickerWidget />
<hr className="border-gray-300" />
<DipSenTickerWidget />
<hr className="border-gray-300" />
<SenadoresPorSeccionWidget />
<hr className="border-gray-300" />
<DiputadosPorSeccionWidget />
<hr className="border-gray-300" />
<ConcejalesPorSeccionWidget />
<hr className="border-gray-300" />
<SenadoresWidget />
<hr className="border-gray-300" />
<DiputadosWidget />
<hr className="border-gray-300" />
<ConcejalesWidget />
<hr className="border-gray-300" />
<CongresoWidget />
<hr className="border-gray-300" />
<BancasWidget />
<hr className="border-gray-300" />
<MapaBsAs />
<hr className="border-gray-300" />
<MapaBsAsSecciones />
<hr className="border-gray-300" />
<TelegramaWidget />
<hr className="border-gray-300" />
<ResultadosTablaDetalladaWidget />
<hr className="border-gray-300" />
<ResultadosRankingMunicipioWidget />
</main>
</>
)

View File

@@ -1,8 +1,30 @@
// src/apiService.ts
import axios from 'axios';
import type { ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker } from './types/types';
import type {
ApiResponseRankingMunicipio, ApiResponseRankingSeccion,
ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple,
TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker,
ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia,
CategoriaResumenHome
} from './types/types';
const API_BASE_URL = 'http://localhost:5217/api';
/**
* URL base para las llamadas a la API.
* - En desarrollo, apunta directamente al backend de .NET.
* - En producción, apunta al endpoint público de la API.
*/
export const API_BASE_URL = import.meta.env.DEV
? 'http://localhost:5217/api'
: 'https://elecciones2025.eldia.com/api';
/**
* URL base para los activos estáticos (imágenes, etc.) de la carpeta `public`.
* - En desarrollo, es una ruta relativa a la raíz (servida por Vite).
* - En producción, es la URL absoluta del dominio donde se alojan los widgets.
*/
export const assetBaseUrl = import.meta.env.DEV
? ''
: 'https://elecciones2025.eldia.com';
const apiClient = axios.create({
baseURL: API_BASE_URL,
@@ -57,7 +79,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 {
@@ -68,21 +89,58 @@ 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): Promise<ProyeccionBancas> => {
const response = await apiClient.get(`/resultados/bancas/${seccionId}`);
return response.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;
};
/**
* Obtiene la lista de Secciones Electorales desde la API.
*/
export const getSeccionesElectorales = async (): Promise<MunicipioSimple[]> => {
const response = await apiClient.get('/catalogos/secciones-electorales');
export const getSeccionesElectorales = async (categoriaId?: number): Promise<MunicipioSimple[]> => {
let url = '/catalogos/secciones-electorales';
// Si se proporciona una categoría, la añadimos a la URL
if (categoriaId) {
url += `?categoriaId=${categoriaId}`;
}
const response = await apiClient.get(url);
return response.data;
};
@@ -119,13 +177,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;
};
@@ -134,24 +192,110 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> =
return response.data;
};
export const getResultadosConcejales = async (seccionId: string): Promise<ResultadoTicker[]> => {
const response = await apiClient.get(`/resultados/concejales/${seccionId}`);
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}`);
return response.data;
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 getResultadosConcejalesPorMunicipio = async (municipioId: string): Promise<ResultadoTicker[]> => {
// Usamos el endpoint 'partido' que, según la aclaración de la API, busca por municipio
const response = await apiClient.get(`/resultados/partido/${municipioId}`);
// La API devuelve 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;
};
export const getMunicipios = async (): Promise<MunicipioSimple[]> => {
const response = await apiClient.get('/catalogos/municipios');
export const getMunicipios = async (categoriaId?: number): Promise<MunicipioSimple[]> => {
let url = '/catalogos/municipios';
if (categoriaId) {
url += `?categoriaId=${categoriaId}`;
}
const response = await apiClient.get(url);
// --- CORRECCIÓN ---
// La API devuelve un array de objetos con las propiedades { id, nombre }.
// Ya no es necesario mapear. Simplemente devolvemos los datos como vienen.
return response.data;
};
export const getSeccionesElectoralesConCargos = async (): Promise<MunicipioSimple[]> => {
// Hacemos la petición al nuevo endpoint del backend
const { data } = await apiClient.get<MunicipioSimple[]>('/resultados/secciones-electorales-con-cargos');
return data;
};
export const getResultadosTablaDetallada = async (seccionId: string): Promise<ApiResponseTablaDetallada> => {
const { data } = await apiClient.get(`/resultados/tabla-ranking-seccion/${seccionId}`);
return data;
};
export const getRankingResultadosPorSeccion = async (seccionId: string): Promise<ApiResponseRankingSeccion> => {
const { data } = await apiClient.get(`/resultados/ranking-por-seccion/${seccionId}`);
return data;
};
export const getRankingMunicipiosPorSeccion = async (seccionId: string): Promise<ApiResponseRankingMunicipio> => {
const { data } = await apiClient.get(`/resultados/ranking-municipios-por-seccion/${seccionId}`);
return data;
};
export const getEstablecimientosPorMunicipio = async (municipioId: string): Promise<CatalogoItem[]> => {
const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`);
return response.data;
};
export const getPanelElectoral = async (eleccionId: number, ambitoId: string | null, categoriaId: number): Promise<PanelElectoralDto> => {
// Construimos la URL base
let url = ambitoId
? `/elecciones/${eleccionId}/panel/${ambitoId}`
: `/elecciones/${eleccionId}/panel`;
// Añadimos categoriaId como un query parameter
url += `?categoriaId=${categoriaId}`;
const { data } = await apiClient.get(url);
return data;
};
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 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;
};

View File

@@ -1,41 +0,0 @@
/* src/components/BancasWidget.css */
.bancas-widget-container {
/* Mismo estilo de tarjeta que el Ticker */
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 15px 20px;
border-radius: 8px;
max-width: 800px;
margin: 20px auto;
color: #333333;
}
.bancas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.bancas-header h4 {
margin: 0;
color: #212529;
font-size: 1.2em;
font-weight: 700;
}
.bancas-header select {
background-color: #ffffff;
color: #333333;
border: 1px solid #ced4da; /* Borde estándar para inputs */
border-radius: 4px;
padding: 5px 10px;
font-family: inherit;
font-size: 0.9em;
}
.waffle-chart-container {
height: 300px;
font-family: "Public Sans", system-ui, sans-serif;
}

View File

@@ -1,127 +0,0 @@
// src/components/BancasWidget.tsx
import { useState, useEffect } from 'react';
// Se cambia la importación: de ResponsiveWaffle a ResponsiveBar
import { ResponsiveBar } from '@nivo/bar';
import { getBancasPorSeccion, getSeccionesElectorales } from '../apiService';
import type { ProyeccionBancas, MunicipioSimple } from '../types/types';
import './BancasWidget.css';
// La paleta de colores se mantiene para consistencia visual
const NIVO_COLORS = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf"];
export const BancasWidget = () => {
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
const [seccionActual, setSeccionActual] = useState<string>('');
const [data, setData] = useState<ProyeccionBancas | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// useEffect para cargar la lista de secciones una sola vez
useEffect(() => {
const fetchSecciones = async () => {
try {
const seccionesData = await getSeccionesElectorales();
if (seccionesData && seccionesData.length > 0) {
// --- INICIO DE LA LÓGICA DE ORDENAMIENTO ---
const orden = new Map([
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
]);
const getOrden = (nombre: string) => {
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
return match ? orden.get(match[0]) ?? 99 : 99;
};
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
// --- FIN DE LA LÓGICA DE ORDENAMIENTO ---
setSecciones(seccionesData);
setSeccionActual(seccionesData[0].id);
} else {
setError("No se encontraron secciones electorales.");
}
} catch (err) {
console.error("Error cargando secciones electorales:", err);
setError("No se pudo cargar la lista de secciones.");
}
};
fetchSecciones();
}, []);
// useEffect para cargar los datos de bancas cuando cambia la sección
useEffect(() => {
if (!seccionActual) return;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const result = await getBancasPorSeccion(seccionActual);
setData(result);
} catch (err) {
console.error(`Error cargando datos de bancas para sección ${seccionActual}:`, err);
setData(null);
setError("No hay datos de bancas disponibles para esta sección.");
} finally {
setLoading(false);
}
};
fetchData();
}, [seccionActual]);
// Se preparan los datos para el gráfico de barras.
// Se invierte el array para que el partido con más bancas aparezca arriba.
const barData = data ? [...data.proyeccion].reverse() : [];
return (
<div className="bancas-widget-container">
<div className="bancas-header">
<h4>Distribución de Bancas</h4>
<select value={seccionActual} onChange={e => setSeccionActual(e.target.value)} disabled={secciones.length === 0}>
{secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}
</select>
</div>
<div className="waffle-chart-container">
{loading ? <p>Cargando...</p> : error ? <p>{error}</p> :
// --- SE REEMPLAZA EL GRÁFICO WAFFLE POR EL GRÁFICO DE BARRAS ---
<ResponsiveBar
data={barData}
keys={['bancas']}
indexBy="agrupacionNombre"
layout="horizontal"
margin={{ top: 10, right: 30, bottom: 25, left: 160 }}
padding={0.3}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={({ index }) => NIVO_COLORS[index % NIVO_COLORS.length]}
borderColor={{ from: 'color', modifiers: [['darker', 1.6]] }}
axisTop={null}
axisRight={null}
axisBottom={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
legend: 'Cantidad de Bancas',
legendPosition: 'middle',
legendOffset: 20,
// Asegura que los ticks del eje sean números enteros
format: (value) => Math.floor(value) === value ? value : ''
}}
axisLeft={{
tickSize: 5,
tickPadding: 5,
tickRotation: 0,
}}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={{ from: 'color', modifiers: [['darker', 3]] }}
animate={true}
// Se elimina la leyenda, ya que las etiquetas en el eje son suficientes
legends={[]}
/>}
</div>
</div>
);
};

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,139 +0,0 @@
// src/components/TelegramaWidget.tsx
import { useState, useEffect } from 'react';
import {
getSecciones,
getMunicipiosPorSeccion,
getCircuitosPorMunicipio,
getEstablecimientosPorCircuito,
getMesasPorEstablecimiento,
getTelegramaPorId
} from '../apiService';
import type { TelegramaData, CatalogoItem } from '../types/types';
import './TelegramaWidget.css';
import { pdfjs, Document, Page } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
export const TelegramaWidget = () => {
// Estados para los filtros geográficos
const [secciones, setSecciones] = useState<CatalogoItem[]>([]);
const [municipios, setMunicipios] = useState<CatalogoItem[]>([]);
const [circuitos, setCircuitos] = useState<CatalogoItem[]>([]);
const [establecimientos, setEstablecimientos] = useState<CatalogoItem[]>([]);
const [mesas, setMesas] = useState<CatalogoItem[]>([]);
// Estados para los valores seleccionados
const [selectedSeccion, setSelectedSeccion] = useState('');
const [selectedMunicipio, setSelectedMunicipio] = useState('');
const [selectedCircuito, setSelectedCircuito] = useState('');
const [selectedEstablecimiento, setSelectedEstablecimiento] = useState('');
const [selectedMesa, setSelectedMesa] = useState('');
// Estados para la visualización del telegrama
const [telegrama, setTelegrama] = useState<TelegramaData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cargar secciones iniciales
useEffect(() => {
getSecciones().then(setSecciones);
}, []);
// Cargar municipios cuando cambia la sección
useEffect(() => {
if (selectedSeccion) {
setMunicipios([]); setCircuitos([]); setEstablecimientos([]); setMesas([]);
setSelectedMunicipio(''); setSelectedCircuito(''); setSelectedEstablecimiento(''); setSelectedMesa('');
getMunicipiosPorSeccion(selectedSeccion).then(setMunicipios);
}
}, [selectedSeccion]);
useEffect(() => {
if (selectedMunicipio) {
setCircuitos([]); setEstablecimientos([]); setMesas([]);
setSelectedCircuito(''); setSelectedEstablecimiento(''); setSelectedMesa('');
getCircuitosPorMunicipio(selectedMunicipio).then(setCircuitos);
}
}, [selectedMunicipio]);
useEffect(() => {
if (selectedCircuito) {
setEstablecimientos([]); setMesas([]);
setSelectedEstablecimiento(''); setSelectedMesa('');
getEstablecimientosPorCircuito(selectedCircuito).then(setEstablecimientos);
}
}, [selectedCircuito]);
useEffect(() => {
if (selectedEstablecimiento) {
setMesas([]);
setSelectedMesa('');
getMesasPorEstablecimiento(selectedEstablecimiento).then(setMesas);
}
}, [selectedEstablecimiento]);
// Buscar el telegrama cuando se selecciona una mesa
useEffect(() => {
if (selectedMesa) {
setLoading(true);
setError(null);
setTelegrama(null);
getTelegramaPorId(selectedMesa)
.then(setTelegrama)
.catch(() => setError(`No se encontró el telegrama para la mesa seleccionada.`))
.finally(() => setLoading(false));
}
}, [selectedMesa]);
return (
<div className="telegrama-container">
<h4>Consulta de Telegramas por Ubicación</h4>
<div className="filters-grid">
<select value={selectedSeccion} onChange={e => setSelectedSeccion(e.target.value)}>
<option value="">1. Sección</option>
{secciones.map(s => <option key={s.id} value={s.id}>{s.nombre}</option>)}
</select>
<select value={selectedMunicipio} onChange={e => setSelectedMunicipio(e.target.value)} disabled={!municipios.length}>
<option value="">2. Municipio</option>
{municipios.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)}
</select>
<select value={selectedCircuito} onChange={e => setSelectedCircuito(e.target.value)} disabled={!circuitos.length}>
<option value="">3. Circuito</option>
{circuitos.map(c => <option key={c.id} value={c.id}>{c.nombre}</option>)}
</select>
<select value={selectedEstablecimiento} onChange={e => setSelectedEstablecimiento(e.target.value)} disabled={!establecimientos.length}>
<option value="">4. Establecimiento</option>
{establecimientos.map(e => <option key={e.id} value={e.id}>{e.nombre}</option>)}
</select>
<select value={selectedMesa} onChange={e => setSelectedMesa(e.target.value)} disabled={!mesas.length}>
<option value="">5. Mesa</option>
{mesas.map(m => <option key={m.id} value={m.id}>{m.nombre}</option>)}
</select>
</div>
<div className="telegrama-viewer">
{loading && <div className="spinner"></div>}
{error && <p className="message error">{error}</p>}
{telegrama && (
<div className="telegrama-content">
<div className="telegrama-pdf-viewer">
<Document
file={`data:application/pdf;base64,${telegrama.contenidoBase64}`}
onLoadError={(error) => setError(`Error al cargar el PDF: ${error.message}`)}
loading={<div className="spinner"></div>}
>
<Page pageNumber={1} renderTextLayer={false} renderAnnotationLayer={false} />
</Document>
</div>
</div>
)}
{!loading && !telegrama && !error && <p className="message">Seleccione una mesa para visualizar el telegrama.</p>}
</div>
</div>
);
};

View File

@@ -1,112 +0,0 @@
/* src/components/TickerWidget.css */
.ticker-wrapper {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
width: 100%;
max-width: 1280px;
margin: 20px auto;
}
.ticker-card {
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
padding: 15px 20px;
border-radius: 8px;
display: flex;
flex-direction: column;
}
.ticker-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #e0e0e0; /* Borde más claro */
padding-bottom: 10px;
margin-bottom: 15px;
}
.ticker-header h3 {
margin: 0;
color: #212529; /* Color de título oscuro */
font-size: 1.2em;
font-weight: 700;
}
.ticker-stats {
display: flex;
gap: 20px;
font-size: 0.9em;
color: #555;
}
.ticker-stats strong {
color: #0073e6; /* Se usa el azul primario para destacar */
font-size: 1.1em;
}
.ticker-results {
display: flex;
flex-direction: column;
gap: 12px; /* Espacio entre partidos */
}
.ticker-party .party-info {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 0.9em;
}
.ticker-party .party-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 10px;
}
.ticker-party .party-percent {
font-weight: 700;
}
.party-bar-background {
background-color: #e9ecef; /* Fondo de barra claro */
border-radius: 4px;
height: 10px;
overflow: hidden;
}
.party-bar-foreground {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
/* El color de fondo se sigue aplicando desde el componente, esto es correcto */
}
.ticker-results {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); /* Aumentamos el tamaño mínimo */
gap: 20px;
}
.ticker-party {
display: flex;
align-items: center;
gap: 10px; /* Espacio entre logo y detalles */
}
.party-logo {
flex-shrink: 0;
width: 75px;
height: 75px;
}
.party-logo img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
border: 1px solid #ddd;
}
.party-details {
flex-grow: 1;
min-width: 0; /* Previene que el flex item se desborde */
}

View File

@@ -0,0 +1,87 @@
// 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 = () => {
return (
<>
<h1 style={{ textAlign: 'center', fontFamily: 'sans-serif' }}>
Showcase de Widgets - Elecciones 2025
</h1>
<main>
<DipSenTickerWidget />
<ResumenGeneralWidget />
<SenadoresWidget />
<DiputadosWidget />
<ConcejalesWidget />
<SenadoresTickerWidget />
<DiputadosTickerWidget />
<ConcejalesTickerWidget />
<DiputadosPorSeccionWidget />
<SenadoresPorSeccionWidget />
<ConcejalesPorSeccionWidget />
<CongresoWidget eleccionId={1} />
<BancasWidget />
<MapaBsAs />
<MapaBsAsSecciones />
<TelegramaWidget />
<ResultadosTablaDetalladaWidget />
<ResultadosRankingMunicipioWidget />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Vista General (Por Defecto)</h2>
<p>Carga la vista provincial completa para Diputados.</p>
<MapaBsAs />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Foco en La Plata (Diputados por defecto)</h2>
<p>Carga el mapa y automáticamente hace zoom en La Plata.</p>
<MapaBsAs focoMunicipio="LA PLATA" />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Foco en Campana</h2>
<p>Carga el mapa y automáticamente hace zoom en Campana.</p>
<MapaBsAs focoMunicipio="CAMPANA" focoCategoria="senadores" />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Vista General de Senadores</h2>
<p>Carga la vista provincial completa para la categoría Senadores.</p>
<MapaBsAs focoCategoria="senadores" />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Foco en Bahía Blanca para Concejales</h2>
<p>Carga el mapa enfocado en Bahía Blanca y con la categoría Concejales seleccionada.</p>
<MapaBsAs focoMunicipio="BAHIA BLANCA" focoCategoria="concejales" />
<hr className="border-gray-300 my-8" />
<h2>Mapa - Foco Inválido (La Plata para Senadores)</h2>
<p>Debería mostrar la vista provincial de Senadores y un warning en la consola del navegador.</p>
<MapaBsAs focoMunicipio="LA PLATA" focoCategoria="senadores" />
</main>
</>
);
};

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,5 +1,6 @@
// src/components/ParliamentLayout.tsx
// src/components/common/ParliamentLayout.tsx
import React, { useLayoutEffect } from 'react';
import { assetBaseUrl } from '../../apiService';
import { handleImageFallback } from './imageFallback';
// Interfaces (no cambian)
@@ -28,7 +29,7 @@ export const ParliamentLayout: React.FC<ParliamentLayoutProps> = ({
// HOOK DE IMAGENES POR DEFECTO
useLayoutEffect(() => {
// Se ejecuta después de que el componente y el tooltip se hayan renderizado
handleImageFallback('.seat-tooltip img', '/default-avatar.png');
handleImageFallback('.seat-tooltip img', `${assetBaseUrl}/default-avatar.png`);
}, [seatData, presidenteBancada]); // Dependencias: se vuelve a ejecutar si estos datos cambian
const uniqueColors = [...new Set(seatData.map(d => d.color))];
@@ -165,7 +166,7 @@ export const ParliamentLayout: React.FC<ParliamentLayoutProps> = ({
strokeWidth: 1.5,
'data-tooltip-id': seat.ocupante ? 'seat-tooltip' : undefined,
'data-tooltip-html': seat.ocupante
? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || '/default-avatar.png'}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>`
? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || `${assetBaseUrl}/default-avatar.png`}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>`
: undefined,
});
});

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,6 +1,7 @@
// src/components/SenateLayout.tsx
// src/components/common/SenateLayout.tsx
import React, { useLayoutEffect } from 'react';
import { handleImageFallback } from './imageFallback';
import { assetBaseUrl } from '../../apiService';
// Interfaces
interface SeatFillData {
@@ -28,7 +29,7 @@ export const SenateLayout: React.FC<SenateLayoutProps> = ({
// HOOK DE IMAGENES POR DEFECTO
useLayoutEffect(() => {
// Se ejecuta después de que el componente y el tooltip se hayan renderizado
handleImageFallback('.seat-tooltip img', '/default-avatar.png');
handleImageFallback('.seat-tooltip img', `${assetBaseUrl}/default-avatar.png`);
}, [seatData, presidenteBancada]); // Dependencias: se vuelve a ejecutar si estos datos cambian
const uniqueColors = [...new Set(seatData.map(d => d.color).filter(Boolean))];
@@ -120,7 +121,7 @@ export const SenateLayout: React.FC<SenateLayoutProps> = ({
strokeWidth: 1.5,
'data-tooltip-id': seat.ocupante ? 'seat-tooltip' : undefined,
'data-tooltip-html': seat.ocupante
? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || '/default-avatar.png'}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>`
? `<div class="seat-tooltip"><img src="${seat.ocupante.fotoUrl || `${assetBaseUrl}/default-avatar.png`}" alt="${seat.ocupante.nombreOcupante}" /><p>${seat.ocupante.nombreOcupante}</p></div>`
: undefined,
});
});

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,147 @@
// 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'
// --- 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: '1rem 2rem',
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">
<h1>Visor de Widgets</h1>
<div style={sectionStyle}>
<h2>Widget: Carrusel de Resultados (Home)</h2>
<p style={descriptionStyle}>
Uso: <code style={codeStyle}>&lt;HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={2} titulo="Diputados - Provincia de Buenos Aires" /&gt;</code>
</p>
<HomeCarouselWidget
eleccionId={2} // Nacional
distritoId="02" // Buenos Aires
categoriaId={2} // Diputados Nacionales
titulo="Diputados - Provincia de Buenos Aires"
/>
</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 1): <code style={codeStyle}>&lt;ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={1} /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={1} />
<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={1} cantidadResultados={1} /&gt;</code>
</p>
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={1} cantidadResultados={1} />
</div>
{/* --- OTROS WIDGETS --- */}
<CongresoNacionalWidget eleccionId={2} />
<PanelNacionalWidget eleccionId={2} />
</div>
);
};

View File

@@ -0,0 +1,50 @@
.container{
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,162 @@
// 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 '../provinciales/CongresoWidget.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: 30000,
});
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]);
return (
<div className="congreso-container">
<div className="congreso-grafico">
<div
className={`congreso-hemiciclo-wrapper ${isHovering ? 'is-hovering' : ''}`}
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="congreso-footer">
<div className="footer-legend">
<div className="footer-legend-item">
{/* Usamos la nueva clase CSS para el círculo sólido */}
<span className="legend-icon legend-icon--solid"></span>
<span>Bancas en juego</span>
</div>
<div className="footer-legend-item">
{/* Reemplazamos el SVG por un span con la nueva clase para el anillo */}
<span className="legend-icon legend-icon--ring"></span>
<span>Bancas previas</span>
</div>
</div>
<div className="footer-timestamp">
Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)}
</div>
</div>
</div>
<div className="congreso-summary">
<div className="chamber-tabs">
<button className={camaraActiva === 'diputados' ? 'active' : ''} onClick={() => setCamaraActiva('diputados')}>
Diputados
</button>
<button className={camaraActiva === 'senadores' ? 'active' : ''} onClick={() => setCamaraActiva('senadores')}>
Senadores
</button>
</div>
<h3>{datosCamaraActual.camaraNombre}</h3>
<div className="summary-metric">
<span>Total de Bancas</span>
<strong>{datosCamaraActual.totalBancas}</strong>
</div>
<div className="summary-metric">
<span>Bancas en Juego</span>
<strong>{datosCamaraActual.bancasEnJuego}</strong>
</div>
<hr />
<div className="partido-lista-container">
<ul className="partido-lista">
{partidosOrdenados
.filter(p => p.bancasTotales > 0)
.map((partido: PartidoComposicionNacional) => (
<li key={partido.id}>
<span className="partido-color-box" style={{ backgroundColor: partido.color || '#808080' }}></span>
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
<strong
className="partido-bancas"
title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`}
>
{partido.bancasTotales}
</strong>
</li>
))}
</ul>
</div>
</div>
<Tooltip id="party-tooltip" />
</div>
);
};
export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => {
return (
<Suspense fallback={<div className="congreso-container 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,239 @@
/* src/features/legislativas/nacionales/HomeCarouselWidget.css */
.home-carousel-widget {
--primary-text: #212529;
--secondary-text: #6c757d;
--border-color: #dee2e6;
--background-light: #f8f9fa;
--background-white: #ffffff;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
--font-family-sans: "Roboto", system-ui, sans-serif;
}
.home-carousel-widget {
font-family: var(--font-family-sans);
background-color: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem;
max-width: 1200px;
margin: 2rem auto;
}
.widget-title {
font-size: 1.2rem;
font-weight: 900;
color: var(--primary-text);
margin: 0 0 0.5rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
}
.top-stats-bar {
display: flex;
justify-content: space-around;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.3rem 0.5rem;
margin-bottom: 0.5rem;
}
.top-stats-bar > div {
display: flex;
align-items: baseline;
gap: 0.5rem;
border-right: 1px solid var(--border-color);
padding: 0 0.5rem;
flex-grow: 1;
justify-content: center;
}
.top-stats-bar > div:last-child { border-right: none; }
.top-stats-bar span { font-size: 0.9rem; color: var(--secondary-text); }
.top-stats-bar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); }
.candidate-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--background-white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 0.75rem;
box-shadow: var(--shadow);
height: 100%;
border-left: 5px solid;
border-left-color: var(--candidate-color, #ccc);
position: relative;
}
.candidate-photo-wrapper {
flex-shrink: 0;
width: 60px;
height: 60px;
border-radius: 8px;
overflow: hidden;
background-color: var(--candidate-color, #e9ecef);
}
.candidate-photo {
width: 100%;
height: 100%;
object-fit: cover;
box-sizing: border-box;
}
.candidate-details {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
}
.candidate-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items:flex-start;
gap: 0.1rem;
min-width: 0;
margin-right: 0.75rem;
}
.candidate-name, .party-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
}
.candidate-name {
font-size: 0.95rem;
text-align: left;
font-weight: 700;
color: var(--primary-text);
}
.party-name {
font-size: 0.8rem;
text-align: left;
text-transform: uppercase;
color: var(--secondary-text);
text-transform: uppercase;
}
.candidate-results { text-align: right; flex-shrink: 0; }
.percentage {
display: block;
font-size: 1.2rem;
font-weight: 700;
color: var(--primary-text);
line-height: 1.1;
}
.votes {
font-size: 0.75rem;
color: var(--secondary-text);
white-space: nowrap;
}
.swiper-slide:not(:last-child) .candidate-card::after {
content: '';
position: absolute;
right: -8px;
top: 20%;
bottom: 20%;
width: 1px;
background-color: var(--border-color);
}
.swiper-button-prev, .swiper-button-next {
width: 30px; height: 30px;
background-color: rgba(255, 255, 255, 0.9);
border: 1px solid var(--border-color);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s;
color: var(--secondary-text);
}
.swiper-button-prev:after, .swiper-button-next:after {
font-size: 18px;
font-weight: bold;
}
.swiper-button-prev { left: -10px; }
.swiper-button-next { right: -10px; }
.swiper-button-disabled { opacity: 0; pointer-events: none; }
.widget-footer {
text-align: right;
font-size: 0.75rem;
color: var(--secondary-text);
margin-top: 0.5rem;
}
.short-text {
display: none; /* Oculto por defecto en la vista de escritorio */
}
/* --- INICIO DE LA SECCIÓN DE ESTILOS PARA MÓVIL --- */
@media (max-width: 768px) {
.home-carousel-widget {
padding: 0.75rem;
}
/* 1. Centrar el título en móvil */
.widget-title {
text-align: center;
font-size: 1.1rem;
}
/* 2. Reestructurar la barra de estadísticas a 2x2 y usar textos cortos */
.top-stats-bar {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.2rem;
padding: 0.3rem;
}
.top-stats-bar > div {
padding: 0.25rem 0.5rem;
border-right: none; /* Quitar todos los bordes derechos */
}
.top-stats-bar > div:nth-child(odd) {
border-right: 1px solid var(--border-color); /* Restablecer borde solo para la columna izquierda */
}
/* Lógica de visibilidad de textos */
.long-text {
display: none; /* Ocultar el texto largo en móvil */
}
.short-text {
display:inline; /* Mostrar el texto corto en móvil */
}
/* Reducir fuentes para que quepan mejor */
.top-stats-bar span { font-size: 0.8rem; text-align: left; }
.top-stats-bar strong { font-size: 0.85rem; text-align: right;}
/* --- Botones del Carrusel (sin cambios) --- */
.swiper-button-prev, .swiper-button-next {
width: 32px;
height: 32px;
top: 45%;
}
.swiper-button-prev { left: 2px; }
.swiper-button-next { right: 2px; }
/* --- Ajustes en la tarjeta (sin cambios) --- */
.candidate-card { gap: 0.5rem; padding: 0.5rem; }
.candidate-photo-wrapper { width: 50px; height: 50px; }
.candidate-name { font-size: 0.9rem; }
.percentage { font-size: 1.1rem; }
.votes { font-size: 0.7rem; }
/* 3. Centrar el footer en móvil */
.widget-footer {
text-align: center;
}
}

View File

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

View File

@@ -0,0 +1,802 @@
/* src/features/legislativas/nacionales/PanelNacional.css */
.panel-nacional-container {
font-family: 'Roboto', sans-serif;
max-width: 1200px;
margin: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
position: relative;
}
.panel-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e0e0e0;
position: relative;
/* Necesario para que z-index funcione */
z-index: 20;
/* Un número alto para ponerlo al frente */
background-color: white;
/* Asegura que no sea transparente */
}
/* Contenedor para alinear título y selector */
.header-top-row {
display: flex;
justify-content: flex-start; /* Alinea los items al inicio */
align-items: center;
gap: 2rem; /* Añade un espacio de separación de 2rem entre el selector y el breadcrumb */
}
.categoria-selector {
min-width: 220px;
}
/* El contenedor principal del selector (la parte visible antes de hacer clic) */
.categoria-selector__control {
border-radius: 8px !important;
/* Coincide con el radio de los otros elementos */
border: 1px solid #e0e0e0 !important;
box-shadow: none !important;
/* Quitamos la sombra por defecto */
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
/* Estilo cuando el selector está enfocado (seleccionado) */
.categoria-selector__control--is-focused {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
/* El texto del valor seleccionado */
.categoria-selector__single-value {
font-weight: 500;
color: #333;
}
/* El menú desplegable que contiene las opciones */
.categoria-selector__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;
/* Pequeño espacio entre el control y el menú */
}
/* Cada una de las opciones en la lista */
.categoria-selector__option {
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
}
/* Estilo de una opción cuando pasas el mouse por encima (estado 'focused') */
.categoria-selector__option--is-focused {
background-color: #f0f8ff;
/* Un azul muy claro */
color: #333;
}
/* Estilo de la opción que está actualmente seleccionada */
.categoria-selector__option--is-selected {
background-color: #007bff;
color: white;
}
/* La pequeña línea vertical que separa el contenido del indicador (la flecha) */
.categoria-selector__indicator-separator {
display: none;
/* La ocultamos para un look más limpio */
}
/* El indicador (la flecha hacia abajo) */
.categoria-selector__indicator {
color: #a0a0a0;
transition: color 0.2s;
}
.categoria-selector__indicator:hover {
color: #333;
}
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
.breadcrumbs-container {
display: flex;
align-items: center;
gap: 0.5rem;
/* Espacio entre elementos */
font-size: 1rem;
}
.breadcrumb-item,
.breadcrumb-item-actual {
display: flex;
align-items: center;
padding: 0.4rem 0.8rem;
border-radius: 8px;
/* Bordes redondeados para efecto píldora */
transition: background-color 0.2s ease-in-out;
}
.breadcrumb-item {
background-color: #f0f0f0;
border: 1px solid #e0e0e0;
color: #333;
cursor: pointer;
font-weight: 500;
}
.breadcrumb-item:hover {
background-color: #e0e0e0;
border-color: #d1d1d1;
}
.breadcrumb-item-actual {
background-color: transparent;
color: #000;
font-weight: 700;
/* Más peso para el nivel actual */
}
.breadcrumb-icon {
margin-right: 0.4rem;
font-size: 1rem;
}
.breadcrumb-separator {
color: #a0a0a0;
/* Color sutil para el separador */
font-size: 1.2rem;
}
.panel-main-content {
display: flex;
height: 75vh;
min-height: 500px;
transition: all 0.5s ease-in-out;
}
/* Columna del mapa */
.mapa-column {
flex: 2;
/* Por defecto, ocupa 2/3 del espacio */
position: relative;
transition: flex 0.5s ease-in-out;
}
/* Columna de resultados */
.resultados-column {
flex: 1;
/* Por defecto, ocupa 1/3 */
overflow-y: auto;
padding: 1.5rem;
transition: all 0.5s ease-in-out;
min-width: 320px;
/* Un ancho mínimo para que no se comprima demasiado */
}
/* --- NUEVO LAYOUT PARA TARJETAS DE PARTIDO --- */
.partido-fila {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
border-radius: 12px;
padding-left: 1rem;
}
.partido-logo {
flex-shrink: 0;
width: 75px;
height: 75px;
}
.partido-logo img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 10%;
}
.partido-main-content {
flex-grow: 1;
display: grid;
/* CAMBIO: De flex a grid */
grid-template-columns: 1fr auto;
/* Columna 1 (nombre) flexible, Columna 2 (stats) se ajusta al contenido */
grid-template-rows: auto auto;
/* Dos filas: una para la info, otra para la barra */
align-items: center;
/* Alinea verticalmente el contenido de ambas filas */
gap: 0.25rem 1rem;
/* Espacio entre filas y columnas (0.25rem vertical, 1rem horizontal) */
}
.partido-top-row {
/* Hacemos que este contenedor sea "invisible" para el grid,
promoviendo a sus hijos (info y stats) a la cuadrícula principal. */
display: contents;
}
.partido-info-wrapper {
/* Ocupa el espacio disponible a la izquierda */
min-width: 0;
text-align: left;
}
.partido-nombre {
font-weight: 700;
font-size: 1.05rem;
color: #212529;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.candidato-nombre {
font-size: 0.8rem;
color: #6c757d;
text-transform: uppercase;
font-weight: 500;
line-height: 1.1;
}
.partido-stats {
flex-shrink: 0;
text-align: right;
padding-left: 1rem;
/* Ya no necesita ser un contenedor flex, el grid lo posiciona */
}
.partido-porcentaje {
font-size: 1.5rem;
font-weight: 700;
display: block;
}
.partido-votos {
font-size: 1rem;
color: #666;
display: block;
}
.partido-barra-background {
height: 20px;
background-color: #f0f0f0;
border-radius: 4px;
grid-column: 1 / 3;
/* Le indicamos que ocupe ambas columnas (de la línea 1 a la 3) */
}
.partido-barra-foreground {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease-in-out;
}
/* ------------------------------------------- */
.panel-estado-recuento {
display: flex;
justify-content: space-around;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid #e0e0e0;
}
.estado-item {
width: 100px;
text-align: center;
}
.estado-item span {
margin-top: 0.5rem;
font-size: 0.9rem;
color: #666;
display: block;
}
/* --- MAPA Y ELEMENTOS ASOCIADOS --- */
.mapa-componente-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.mapa-render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.mapa-volver-btn {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
padding: 8px 12px;
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.rsm-zoomable-group {
transition: transform 0.75s ease-in-out;
}
/* AÑADIDO: Desactivar la transición durante el arrastre */
.rsm-zoomable-group.panning {
transition: none;
}
.panel-main-content.panel-collapsed .mapa-column {
flex: 1 1 100%;
}
.panel-main-content.panel-collapsed .resultados-column {
flex-basis: 0;
min-width: 0;
max-width: 0;
padding: 0;
overflow: hidden;
}
.panel-toggle-btn {
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;
}
.panel-toggle-btn:hover {
background-color: #f0f0f0;
}
.rsm-geography {
stroke: #000000;
stroke-width: 0.25px;
outline: none;
transition: filter 0.2s ease-in-out;
}
.rsm-geography:not(.selected):hover {
filter: brightness(1.25);
/* Mantenemos el brillo */
stroke: #ffffff;
/* Color del borde a blanco */
stroke-width: 0.25px;
paint-order: stroke;
/* Asegura que el borde se dibuje encima del relleno */
}
.rsm-geography.selected {
stroke: #000000;
stroke-width: 0.25px;
filter: none;
pointer-events: none;
}
.rsm-geography-faded,
.rsm-geography-faded-municipality {
opacity: 0.5;
pointer-events: none;
}
.caba-comuna-geography {
stroke: #000000;
stroke-width: 0.05px;
}
.caba-comuna-geography:not(.selected):hover {
stroke: #000000;
stroke-width: 0.055px;
filter: brightness(1.25);
}
.transition-spinner {
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;
}
.transition-spinner::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);
}
}
.caba-magnifier-container {
position: absolute;
height: auto;
transform: translate(-50%, -50%);
pointer-events: none;
}
.caba-lupa-svg {
width: 100%;
height: auto;
pointer-events: none;
}
.caba-lupa-interactive-area {
pointer-events: all;
cursor: pointer;
filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25));
transition: transform 0.2s ease-in-out;
}
.caba-lupa-interactive-area:hover {
filter: brightness(1.15);
stroke: #ffffff;
stroke-width: 0.25px;
}
.skeleton-fila 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;
}
.skeleton-logo {
width: 65px;
height: 65px;
}
.skeleton-text {
height: 1em;
}
.skeleton-bar {
height: 20px;
margin-top: 4px;
}
/* --- NUEVOS ESTILOS PARA EL TOGGLE MÓVIL --- */
.mobile-view-toggle {
display: none;
position: absolute;
/* <-- CAMBIO: De 'fixed' a 'absolute' */
bottom: 10px;
/* <-- AJUSTE: Menos espacio desde abajo */
left: 50%;
transform: translateX(-50%);
z-index: 100;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 30px;
padding: 5px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 5px;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
}
.mobile-view-toggle .toggle-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
background-color: transparent;
border-radius: 25px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: #555;
transition: all 0.2s ease-in-out;
}
.mobile-view-toggle .toggle-btn.active {
background-color: #007bff;
color: white;
}
/* --- ESTILOS PARA LOS BOTONES DE ZOOM DEL MAPA --- */
.zoom-controls-container {
position: absolute;
top: 5px;
right: 10px;
z-index: 30;
/* Debe ser MAYOR que el z-index del header (20) */
display: flex;
flex-direction: column;
gap: 5px;
}
.zoom-btn {
width: 40px;
height: 40px;
background-color: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: background-color 0.2s;
}
.zoom-icon-wrapper {
/* Contenedor del icono */
display: flex;
/* Necesario para que el SVG interno se alinee */
align-items: center;
justify-content: center;
}
.zoom-icon-wrapper svg {
/* Apunta directamente al SVG del icono */
width: 20px;
height: 20px;
color: #333;
}
.zoom-btn.disabled {
opacity: 0.5;
/* Lo hace semitransparente */
cursor: not-allowed;
/* Muestra el cursor de "no permitido" */
}
.zoom-btn:hover {
background-color: #f0f0f0;
}
/* --- ESTILOS DE CURSOR PARA EL ARRASTRE DEL MAPA --- */
.map-locked .rsm-geography {
cursor: pointer;
}
.map-pannable .rsm-geography {
cursor: grab;
}
/* El cursor 'grabbing' se aplica automáticamente por el navegador durante el arrastre */
/* --- MEDIA QUERY PARA RESPONSIVE (ENFOQUE FINAL CON CAPAS) --- */
@media (max-width: 800px) {
/* --- CONFIGURACIÓN GENERAL --- */
html,
body {
width: 100%;
overflow-x: hidden;
}
/* Controles de vista y header (sin cambios) */
.mobile-view-toggle {
display: flex;
}
.panel-toggle-btn {
display: none;
}
.header-top-row {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.categoria-selector {
width: 100%;
}
/* --- NUEVO LAYOUT DE CAPAS SUPERPUESTAS --- */
/* 1. El contenedor principal ahora es un ancla de posicionamiento */
.panel-main-content {
position: relative;
/* Clave para que los hijos se posicionen dentro de él */
height: calc(100vh - 200px);
/* Le damos una altura fija y predecible */
min-height: 450px;
}
/* 2. Ambas columnas son capas que ocupan el 100% del espacio del padre */
.mapa-column,
.resultados-column {
position: absolute;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
}
/* Le damos un estilo específico a la columna del mapa para subirla */
.mapa-column {
top: -50px;
left: -10px;
z-index: 10;
}
/* Hacemos que la columna de resultados pueda tener su propio scroll... */
.resultados-column {
top: 0;
/* Aseguramos que los resultados se queden en su sitio */
padding: 1rem;
overflow-y: auto;
z-index: 15;
}
/* 3. Lógica de visibilidad: controlamos qué capa está "arriba" */
.panel-main-content.mobile-view-mapa .resultados-column {
opacity: 0;
visibility: hidden;
/* Esta es la propiedad clave que ya tenías, pero es importante verificarla */
pointer-events: none;
/* Asegura que la capa oculta no bloquee el mapa */
}
.panel-main-content.mobile-view-resultados .mapa-column {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
/* Hacemos que la columna de resultados pueda tener su propio scroll si el contenido es largo */
.resultados-column {
padding: 1rem;
overflow-y: auto;
}
/* 4. Estilos de los resultados (ya estaban bien, se mantienen) */
.partido-fila {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid;
/* Grosor del borde */
border-radius: 12px;
/* Redondeamos las esquinas */
padding-left: 1rem;
/* Espacio a la izquierda */
}
.partido-logo {
width: 60px;
height: 60px;
flex-shrink: 0;
}
.partido-main-content {
flex-grow: 1;
min-width: 0;
}
.partido-top-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.partido-info-wrapper {
min-width: 0;
}
.partido-nombre {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.partido-stats {
text-align: right;
flex-shrink: 0;
padding-left: 0.5rem;
}
/* --- AJUSTE DE TAMAÑO DEL CONTENEDOR INTERNO DEL MAPA --- */
.mapa-column .mapa-componente-container,
.mapa-column .mapa-render-area {
height: 100%;
}
/* Margen de seguridad para el último elemento de la lista de resultados */
.panel-partidos-container .partido-fila:last-child {
margin-bottom: 90px;
}
.zoom-controls-container {
top: 55px;
}
.mapa-volver-btn {
top: 55px;
left: 12px;
}
/* --- MEDIA QUERY ADICIONAL PARA MÓVIL EN HORIZONTAL --- */
/* Se activa cuando la pantalla es ancha pero no muy alta, como un teléfono en landscape */
@media (max-width: 900px) and (orientation: landscape) {
/* Layout flexible de dos columnas */
.panel-main-content {
display: flex;
flex-direction: row;
position: static;
height: 85vh;
min-height: 400px;
}
.mapa-column,
.resultados-column {
position: static;
/* Desactivamos el posicionamiento absoluto */
height: auto;
width: auto;
opacity: 1;
visibility: visible;
pointer-events: auto;
flex: 3;
overflow-y: auto;
/* Permitimos que la columna de resultados tenga su propio scroll */
}
.resultados-column {
flex: 2;
min-width: 300px;
/* Un mínimo para que no se comprima */
}
/* 3. Ocultamos los botones de cambio de vista móvil, ya que ambas se ven */
.mobile-view-toggle {
display: none;
}
/* 4. Mostramos de nuevo el botón lateral para colapsar el panel de resultados */
.panel-toggle-btn {
display: flex;
}
}
}

View File

@@ -0,0 +1,152 @@
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
import { useMemo, useState, Suspense } 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 './PanelNacional.css';
import Select from 'react-select';
import type { PanelElectoralDto } from '../../../types/types';
import { FiMap, FiList } from 'react-icons/fi';
import { useMediaQuery } from './hooks/useMediaQuery';
import { Toaster } from 'react-hot-toast';
interface PanelNacionalWidgetProps {
eleccionId: number;
}
type AmbitoState = {
id: string | null;
nivel: 'pais' | 'provincia' | 'municipio';
nombre: string;
provinciaNombre?: string;
provinciaDistritoId?: string | null;
};
const CATEGORIAS_NACIONALES = [
{ value: 2, label: 'Diputados Nacionales' },
{ value: 1, label: 'Senadores Nacionales' },
];
const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => {
const { data } = useSuspenseQuery<PanelElectoralDto>({
queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId],
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId),
});
return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />;
};
export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => {
const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
const [categoriaId, setCategoriaId] = useState<number>(2);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
// --- DETECCIÓN DE VISTA MÓVIL ---
const isMobile = useMediaQuery('(max-width: 800px)');
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
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]
);
return (
<div className="panel-nacional-container">
<Toaster containerClassName="widget-toaster-container" />
<header className="panel-header">
<div className="header-top-row">
<Select
options={CATEGORIAS_NACIONALES}
value={selectedCategoria}
onChange={(option) => option && setCategoriaId(option.value)}
className="categoria-selector"
classNamePrefix="categoria-selector"
isSearchable={false}
/>
<Breadcrumbs
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvincia={ambitoActual.provinciaNombre}
onReset={handleResetToPais}
onVolverProvincia={handleVolverAProvincia}
/>
</div>
</header>
<main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''} ${isMobile ? `mobile-view-${mobileView}` : ''}`}>
<div className="mapa-column">
<button className="panel-toggle-btn" onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}>
{isPanelOpen ? '' : ''}
</button>
<Suspense fallback={<div className="spinner" />}>
<MapaNacional
eleccionId={eleccionId}
categoriaId={categoriaId}
nivel={ambitoActual.nivel}
nombreAmbito={ambitoActual.nombre}
nombreProvinciaActiva={ambitoActual.provinciaNombre}
provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null}
onAmbitoSelect={handleAmbitoSelect}
onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais}
isMobileView={isMobile}
/>
</Suspense>
</div>
<div className="resultados-column">
<Suspense fallback={<div className="spinner" />}>
<PanelContenido
eleccionId={eleccionId}
ambitoActual={ambitoActual}
categoriaId={categoriaId}
/>
</Suspense>
</div>
</main>
{/* --- NUEVO CONTROLADOR DE VISTA PARA MÓVIL --- */}
<div className="mobile-view-toggle">
<button
className={`toggle-btn ${mobileView === 'mapa' ? 'active' : ''}`}
onClick={() => setMobileView('mapa')}
>
<FiMap />
<span>Mapa</span>
</button>
<button
className={`toggle-btn ${mobileView === 'resultados' ? 'active' : ''}`}
onClick={() => setMobileView('resultados')}
>
<FiList />
<span>Resultados</span>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,303 @@
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.css */
/* --- Variables de Diseño --- */
:root {
--card-border-color: #e0e0e0;
--card-bg-color: #ffffff;
--card-header-bg-color: #f8f9fa;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
--text-primary: #212529;
--text-secondary: #6c757d;
--font-family: "Roboto", system-ui, sans-serif;
--primary-accent-color: #007bff;
}
/* --- Contenedor Principal del Widget --- */
.cards-widget-container {
font-family: var(--font-family);
width: 100%;
max-width: 1200px;
margin: 2rem auto;
}
.cards-widget-container 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 --- */
.cards-grid {
display: grid;
/* Crea columnas flexibles que se ajustan al espacio disponible */
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 1.5rem;
align-items: start;
}
/* --- Tarjeta Individual --- */
.provincia-card {
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; /* Asegura que los bordes redondeados se apliquen al contenido */
}
/* --- Cabecera de la Tarjeta --- */
.card-header {
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);
}
.header-info h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
}
.header-info span {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.header-map {
width: 90px;
height: 90px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
background-color: #e9ecef;
padding: 0.25rem;
box-sizing: border-box; /* Para que el padding no aumente el tamaño total */
}
/* Contenedor del SVG para asegurar que se ajuste al espacio */
.map-svg-container, .map-placeholder {
width: 100%;
height: 100%;
}
/* Estilo para el SVG renderizado */
.map-svg-container svg {
width: 100%;
height: 100%;
object-fit: contain; /* Asegura que el mapa no se deforme */
}
/* Placeholder para cuando el mapa no carga */
.map-placeholder.error {
background-color: #f8d7da; /* Un color de fondo rojizo para indicar un error */
}
/* --- Cuerpo de la Tarjeta --- */
.card-body {
padding: 0.5rem 1rem;
}
.candidato-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid #f0f0f0;
border-left: 5px solid; /* Grosor del borde */
border-radius: 12px; /* Redondeamos las esquinas para un look más suave */
padding-left: 1rem; /* Añadimos un poco de espacio a la izquierda */
}
.candidato-row:last-child {
border-bottom: none;
}
.candidato-foto {
width: 60px;
height: 60px;
border-radius: 5%;
object-fit: cover;
flex-shrink: 0;
}
.candidato-data {
flex-grow: 1;
min-width: 0; /* Permite que el texto se trunque si es necesario */
margin-right: 0.5rem;
}
.candidato-nombre {
font-weight: 700;
font-size: 0.95rem;
color: var(--text-primary);
display: block;
text-align: left;
}
.candidato-partido {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
display: block;
margin-bottom: 0.3rem;
text-align: left;
}
.progress-bar-container {
height: 16px;
background-color: #e9ecef;
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease-out;
}
.candidato-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
flex-shrink: 0;
padding-left: 0.5rem;
}
.stats-percent {
font-weight: 700;
font-size: 1.1rem;
color: var(--text-primary);
}
.stats-votos {
font-size: 0.8rem;
color: var(--text-secondary);
}
.stats-bancas {
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;
}
.stats-bancas span {
font-size: 0.65rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
margin-top: -4px;
}
/* --- Pie de la Tarjeta --- */
.card-footer {
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;
}
.card-footer div {
border-right: 1px solid var(--card-border-color);
}
.card-footer div:last-child {
border-right: none;
}
.card-footer span {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
}
.card-footer strong {
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
}
/* --- Media Query para Móvil --- */
@media (max-width: 480px) {
.cards-grid {
/* En pantallas muy pequeñas, forzamos una sola columna */
grid-template-columns: 1fr;
}
.card-header {
padding: 0.5rem;
}
.header-info h3 {
font-size: 1rem;
}
}
/* --- ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */
.candidato-partido.main-title {
font-size: 0.95rem; /* Hacemos la fuente más grande */
font-weight: 700; /* La ponemos en negrita, como el nombre del candidato */
color: var(--text-primary); /* Usamos el color de texto principal */
text-transform: none; /* Quitamos el 'uppercase' para que se lea mejor */
margin-bottom: 0.3rem;
}
/* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */
.categoria-bloque {
width: 100%;
}
/* Añadimos un separador si hay más de una categoría en la misma tarjeta */
.categoria-bloque + .categoria-bloque {
border-top: 1px dashed var(--card-border-color);
margin-top: 1rem;
padding-top: 1rem;
}
.categoria-titulo {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
text-align: center;
margin: 0 0 1rem 0;
}
/* Ajuste para el footer, que ahora está dentro de cada categoría */
.categoria-bloque .card-footer {
grid-template-columns: repeat(3, 1fr);
background-color: transparent; /* Quitamos el fondo gris */
border-top: 1px solid var(--card-border-color);
padding: 0.75rem 0;
margin-top: 0.75rem; /* Espacio antes del footer */
text-align: center;
}
.categoria-bloque .card-footer div {
border-right: 1px solid var(--card-border-color);
}
.categoria-bloque .card-footer div:last-child {
border-right: none;
}

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