Compare commits

207 Commits

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

Cambios principales:

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

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

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

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

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

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

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

Como resultado, los widgets (`HomeCarouselWidget`, `PanelNacionalWidget`, `ResultadosNacionalesCardsWidget`, `CongresoNacionalWidget`) son ahora más robustos, portátiles y visualmente consistentes en cualquier entorno.
2025-10-04 20:41:23 -03:00
fa261ba828 Fix Candidato Partido Estilos Mapa 2025-10-03 15:28:51 -03:00
3c8c4917fd Fix Mapa Style 2025-10-03 15:02:57 -03:00
68f31f2873 Fix Map Control Styles 2025-10-03 14:40:06 -03:00
9e0e7f0ee6 Fix Mapa Res Intermedia 2025-10-03 13:53:11 -03:00
b8c8c1260d Fix Icono Expandible y Top 2025-10-03 13:29:58 -03:00
64d45a7a39 Feat Widgets Controles y Estilos 2025-10-03 13:26:20 -03:00
1719e79723 Fix Styles 2025-10-02 13:38:28 -03:00
e0755a5347 Fix Css 2025-10-02 12:14:45 -03:00
e9b0eeb630 Fix to Prod 2025-10-01 11:59:15 -03:00
63cc042eb4 Merge branch 'Legislativas-Nacionales-2025'
# Conflicts:
#	Elecciones-Web/src/Elecciones.Api/obj/Debug/net9.0/Elecciones.Api.AssemblyInfo.cs
#	Elecciones-Web/src/Elecciones.Core/obj/Debug/net9.0/Elecciones.Core.AssemblyInfo.cs
2025-10-01 10:29:52 -03:00
11d9417ef5 Pre Merge 2025-10-01 10:28:49 -03:00
ed5b78e6c8 Feat Visual en Producción 2025-10-01 10:27:30 -03:00
a985cbfd7c Feat Widgets
- Widget de Home
- Widget Cards por Provincias
- Widget Mapa por Categorias
2025-10-01 10:03:01 -03:00
3b0eee25e6 Feat Widgets Cards y Optimización de Consultas 2025-09-28 19:04:09 -03:00
67634ae947 Fix Panel de Resultados 2025-09-22 17:56:04 -03:00
5a8bee52d5 Fix Arrastre Mapa en Zoom 2025-09-22 09:08:43 -03:00
3750d1a56d Refinamiento de Funciones y Estética de Mapa 2025-09-20 22:31:11 -03:00
7d2922aaeb Pre Refinamiento Movil 2025-09-19 17:19:10 -03:00
3a8f64bf85 Preparación Legislativas Nacionales 2025 2025-09-17 11:31:17 -03:00
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
f961f9d8e7 Feat Widgets 1541 2025-09-02 15:39:17 -03:00
da581d9714 Feat Widgets 1540 2025-09-02 15:39:01 -03:00
271a86b632 Feat Widgets 0209 2025-09-02 09:48:46 -03:00
12860f2406 Feat Widgets 2025-09-01 14:04:40 -03:00
608ae655be Feats y Fixs Varios 2025-08-30 11:31:45 -03:00
3b8c6bf754 Fix bancas widget 2025-08-29 15:49:13 -03:00
1ed9a49a53 Trabajo de ajuste en widgets y db para frontend 2025-08-29 09:54:22 -03:00
55954e18a7 Fix Telegramas 2025-08-25 15:04:09 -03:00
0d33db9e6d Fix widget Telegramas 2025-08-25 12:43:28 -03:00
4a6318c18a Feat Prototipos Widgets y Fix Worker Telegramas 2025-08-25 10:25:54 -03:00
8192185bc5 Fix fecha totalizados nivel provincia 2025-08-23 13:19:35 -03:00
13c6accd15 Fix Añade FechaTotalizado en Proyeccion Bancas 2025-08-23 12:54:57 -03:00
303a469c57 Fix proyeccion bancas 2025-08-23 12:27:27 -03:00
8bb8a5dede Fix dbContext 2025-08-23 11:35:26 -03:00
f88436def6 Fix Service 2025-08-23 11:20:10 -03:00
e5ecdc301e Fix Worker 2025-08-23 11:01:54 -03:00
5de9d6729c Feat Front Widgets Refactizados y Ajustes Backend 2025-08-22 21:55:03 -03:00
18e6e8d3c0 Fix Program.cs 2025-08-20 18:08:17 -03:00
4fb2b87aa1 Retry Bancas 2025-08-20 18:02:17 -03:00
1a6f7dd5a3 Fix Bancas 2 2025-08-20 17:51:26 -03:00
43a967eac2 Fix Bancas 2025-08-20 17:38:51 -03:00
a2bf221194 Fix Paralelizmo en Procesos de baja prioridad 2025-08-20 17:29:50 -03:00
c967da919a Try Separación de Metodos 2025-08-20 16:58:18 -03:00
19b37f7320 Fix Goteo solo para Telegramas 2025-08-20 16:28:17 -03:00
7e1e487e83 Fix Solicitud de Token 2 2025-08-20 15:03:19 -03:00
3d685fba1e Fix Solicitud de Token 2025-08-20 15:03:02 -03:00
9d5c2086c5 Feat Rate Limit para cuotear peticiones. 2025-08-20 14:17:25 -03:00
68dce9415e Fix Sondear Proyeccion Bancas 2025-08-19 18:50:49 -03:00
80a9855acd Fix getBancas 2025-08-19 18:33:54 -03:00
16477a360c Fix Comentado el sonde de telegramas
Deshabilitado hasta obtener limites de consultas por parte del soporte de la API Electoral.
2025-08-19 17:06:02 -03:00
1559a1c3a9 Fix Intento de evitar bloqueo con retraso aleatorio para peticiones. 2025-08-19 16:45:09 -03:00
927658775f Fix Ajuste de paralelismo de 10 a 3 por bloqueos 2025-08-19 16:32:05 -03:00
46ebf7924a Feat Conteo final de telegramas capturados 2025-08-19 15:16:50 -03:00
ccffb0ee7f Fix Telegramas 2025-08-19 14:58:01 -03:00
518e782c8c Fix 1425 2025-08-19 14:25:27 -03:00
506ab37646 Fix Archivos Telegramas en Blanco 2025-08-19 14:23:51 -03:00
94bb7c4360 Feat/Fix: Paralelismo y Coreccion de lista. 2025-08-19 14:07:19 -03:00
b7c50576f4 Fix Cambios de optimizaciones 2025-08-19 09:37:13 -03:00
defb74fcd2 Fix bancas y telegramas 2025-08-18 17:47:11 -03:00
108e92ac27 Fix Worker 1524 2025-08-18 15:25:02 -03:00
0360f0619e Fix Worker 1447 2025-08-18 14:47:05 -03:00
46f6eeae91 Fix Worker 1348 2025-08-18 13:48:55 -03:00
a4e47b6e3d Retry Fix 504 Timeout 2025-08-17 20:47:51 -03:00
eed8d2f065 Fix TimeOut Api 2025-08-17 20:31:25 -03:00
258add9305 Retry Con Cambios Importantes. 2025-08-17 20:08:38 -03:00
30f1e751b7 Fix 1330 2025-08-16 13:30:05 -03:00
931d0f4e91 Fix Campos añadidos 2025-08-16 13:05:44 -03:00
69ddf2b2d2 Fix Nombres Id 2025-08-16 12:27:09 -03:00
2f4027de2f Fix 1200 2025-08-16 12:00:51 -03:00
5a211d93e5 Fix error de nombre 2025-08-16 11:47:10 -03:00
82b53c6c45 Fix ElectoralApiService categoriaId 2025-08-16 11:39:15 -03:00
3c0f382cee Fix Worker 1131 2025-08-16 11:31:49 -03:00
acd1c3373f Fix Campo Id Categoria 2025-08-16 11:25:45 -03:00
b89c4c0b8b Fix catalogo y removido el fake de pruebas 2025-08-16 11:12:54 -03:00
527839dd6d Fix Worker - Categorias 2025-08-16 11:02:23 -03:00
370ea329f5 Fiz Gzip 2025-08-16 10:52:07 -03:00
37ed22b6e3 Fix Tiempo 5 Minutos 2025-08-16 10:42:54 -03:00
9e8eea1c34 Fix 1037 2025-08-16 10:37:54 -03:00
4e0566d654 Fix 0946 2025-08-16 09:46:25 -03:00
16a6664e7c Fix 0937 2025-08-16 09:37:54 -03:00
f11944c6ee Reversión y Test Docker 2025-08-16 09:22:26 -03:00
75ff9d5593 Fix app 2025-08-15 17:47:43 -03:00
bed4a4a638 Rem map 2025-08-15 17:45:54 -03:00
66d14247cf Dockerfile frontend 2025-08-15 17:41:04 -03:00
bce5b1dcec Test Docker 2025-08-15 17:31:51 -03:00
381 changed files with 46688 additions and 1338 deletions

1
.gitignore vendored
View File

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

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

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,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

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

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Elecciones 2025 - El Día</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "frontend-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@tanstack/react-query": "^5.85.5",
"axios": "^1.11.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-select": "^5.10.2"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@types/react-select": "^5.0.0",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.1",
"vite": "^7.1.2"
}
}

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

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
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);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,17 @@
// src/App.tsx
import { useAuth } from './context/AuthContext';
import { LoginPage } from './components/LoginPage';
import { DashboardPage } from './components/DashboardPage';
import './App.css'; // Puede añadir estilos globales aquí
function App() {
const { isAuthenticated } = useAuth();
return (
<div className="App">
{isAuthenticated ? <DashboardPage /> : <LoginPage />}
</div>
);
}
export default App;

View File

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

View File

@@ -0,0 +1,94 @@
/* src/components/AgrupacionesManager.css */
.admin-module {
padding: 1rem;
border: 1px solid #ccc;
border-radius: 8px;
margin-top: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
thead {
background-color: #f2f2f2;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
td input[type="text"] {
width: 100%;
padding: 4px;
box-sizing: border-box;
}
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;
margin: 0;
border: 1px dashed #ccc;
border-radius: 4px;
display: flex; /* <-- La clave para la alineación horizontal */
flex-wrap: wrap; /* <-- La clave para que salte de línea */
gap: 8px; /* Espacio entre elementos */
min-height: 50px; /* Un poco de altura para que la zona de drop sea visible */
}
.sortable-item {
padding: 8px 12px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: grab;
/* Opcional: para que no se puedan seleccionar el texto mientras se arrastra */
user-select: none;
}
.chamber-tabs {
display: flex;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 6px;
overflow: hidden;
}
.chamber-tabs button {
flex: 1;
padding: 0.75rem 0.5rem;
border: none;
background-color: #f8f9fa;
cursor: pointer;
transition: all 0.2s;
}
.chamber-tabs button:first-child { border-right: 1px solid #ccc; }
.chamber-tabs button.active { background-color: #007bff; color: white; }

View File

@@ -0,0 +1,156 @@
// 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, UpdateAgrupacionData } from '../types';
import { AddAgrupacionForm } from './AddAgrupacionForm';
import './AgrupacionesManager.css';
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 { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
queryKey: ['agrupaciones'], queryFn: getAgrupaciones,
});
const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({
queryKey: ['allLogos'],
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
});
const handleCreationSuccess = () => {
// Invalida la query de agrupaciones para forzar una actualización
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
};
useEffect(() => {
if (agrupaciones.length > 0) {
const initialEdits = Object.fromEntries(
agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }])
);
setEditedAgrupaciones(initialEdits);
}
}, [agrupaciones]);
useEffect(() => {
if (logos) {
const logoMap = Object.fromEntries(
logos
// --- CORRECCIÓN CLAVE 1: Comprobar contra `null` en lugar de `0` ---
.filter(l => l.categoriaId === 0 && l.ambitoGeograficoId === null)
.map(l => [`${l.agrupacionPoliticaId}-${l.eleccionId}`, l.logoUrl])
);
setEditedLogos(logoMap);
}
}, [logos]);
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => {
setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }));
};
const handleLogoInputChange = (agrupacionId: string, value: string | null) => {
const key = `${agrupacionId}-${selectedEleccion.value}`;
setEditedLogos(prev => ({ ...prev, [key]: value }));
};
const handleSaveAll = async () => {
try {
const agrupacionPromises = 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(logosPayload);
await Promise.all([...agrupacionPromises, logoPromise]);
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."); }
};
const getLogoValue = (agrupacionId: string): string => {
const key = `${agrupacionId}-${selectedEleccion.value}`;
return editedLogos[key] ?? '';
};
const isLoading = isLoadingAgrupaciones || isLoadingLogos;
return (
<div className="admin-module">
<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> : (
<>
<div className="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Nombre Corto</th>
<th>Color</th>
<th>Logo</th>
</tr>
</thead>
<tbody>
{agrupaciones.map(agrupacion => (
<tr key={agrupacion.id}>
<td>({agrupacion.id}) {agrupacion.nombre}</td>
<td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td>
<td><input type="color" value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color)} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} /></td>
<td>
<input
type="text"
placeholder="URL..."
value={getLogoValue(agrupacion.id)}
onChange={(e) => handleLogoInputChange(agrupacion.id, e.target.value)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button onClick={handleSaveAll} style={{ marginTop: '1rem' }}>
Guardar Todos los Cambios
</button>
<AddAgrupacionForm onSuccess={handleCreationSuccess} />
</>
)}
</div>
);
};

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
// src/components/BancasProvincialesManager.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_PROVINCIAL = 1;
const camaras = ['diputados', 'senadores'] as const;
export const BancasProvincialesManager = () => {
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
});
// --- 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, 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;
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_PROVINCIAL] });
} 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 provinciales.</p>;
return (
<div className="admin-module">
<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 => (
<button
key={camara}
className={activeTab === camara ? 'active' : ''}
onClick={() => setActiveTab(camara)}
>
{camara === 'diputados' ? 'Diputados Provinciales (92)' : 'Senadores Provinciales (46)'}
</button>
))}
</div>
{isLoading ? <p>Cargando bancas...</p> : (
<table>
<thead>
<tr>
<th style={{ width: '15%' }}>Banca #</th>
<th style={{ width: '35%' }}>Partido Asignado</th>
<th style={{ width: '30%' }}>Ocupante Actual</th>
<th style={{ width: '20%' }}>Acciones</th>
</tr>
</thead>
<tbody>
{bancadas.map((bancada) => (
<tr key={bancada.id}>
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
<td>
<select
value={bancada.agrupacionPoliticaId || ''}
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
>
<option value="">-- Vacante --</option>
{agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)}
</select>
</td>
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
<td>
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
Editar Ocupante
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{modalVisible && bancadaSeleccionada && (
<OcupantesModal
bancada={bancadaSeleccionada}
onClose={() => setModalVisible(false)}
activeTab={activeTab}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,120 @@
// 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: 0, label: 'General (Todas las elecciones)' },
{ value: 2, label: 'Elecciones Nacionales' },
{ value: 1, label: 'Elecciones Provinciales' }
];
const AMBITO_LEVEL_OPTIONS = [
{ value: 'general', label: 'General (Toda la elección)' },
{ value: 'provincia', label: 'Por Provincia' },
{ value: 'municipio', label: 'Por Municipio' }
];
export const CandidatoOverridesManager = () => {
const queryClient = useQueryClient();
const [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: ['allCandidatos'],
queryFn: () => Promise.all([getCandidatos(0), getCandidatos(1), getCandidatos(2)]).then(res => res.flat()),
});
const categoriaOptions = useMemo(() => {
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
}, [selectedEleccion]);
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return null;
};
const currentCandidato = useMemo(() => {
if (!selectedAgrupacion || !selectedCategoria) return '';
const ambitoId = getAmbitoId();
return candidatos.find(c =>
c.eleccionId === selectedEleccion.value &&
c.ambitoGeograficoId === ambitoId &&
c.agrupacionPoliticaId === selectedAgrupacion.id &&
c.categoriaId === selectedCategoria.value
)?.nombreCandidato || '';
}, [candidatos, selectedEleccion, 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.trim() || null
};
try {
await updateCandidatos([newCandidatoEntry]);
queryClient.invalidateQueries({ queryKey: ['allCandidatos'] });
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..." />
<Select
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
getOptionValue={opt => opt.id}
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
value={selectedAgrupacion}
onChange={setSelectedAgrupacion}
placeholder="Seleccione Agrupación..."
/>
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
) : <div />}
{selectedAmbitoLevel.value === 'municipio' ? (
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
) : <div />}
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
<div style={{ flex: 1 }}>
<label>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,124 @@
// src/components/ConfiguracionGeneral.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 ConfiguracionGeneral = () => {
const queryClient = useQueryClient();
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tickerCantidad, setTickerCantidad] = useState('3');
const [concejalesCantidad, setConcejalesCantidad] = useState('5');
const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>('');
// Renombramos el estado para mayor claridad
const [modoOficialActivo, setModoOficialActivo] = useState(false);
useEffect(() => {
const loadInitialData = async () => {
try {
setLoading(true);
setError(null);
const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]);
setAgrupaciones(agrupacionesData);
setPresidenciaSenadoId(configData.PresidenciaSenadores || '');
setModoOficialActivo(configData.UsarDatosDeBancadasOficiales === 'true');
setTickerCantidad(configData.TickerResultadosCantidad || '3');
setConcejalesCantidad(configData.ConcejalesResultadosCantidad || '5');
} catch (err) {
console.error("Error al cargar datos de configuración:", err);
setError("No se pudieron cargar los datos necesarios para la configuración.");
} finally { setLoading(false); }
};
loadInitialData();
}, []);
const handleSave = async () => {
try {
await updateConfiguracion({
"PresidenciaSenadores": presidenciaSenadoId,
"UsarDatosDeBancadasOficiales": modoOficialActivo.toString(),
"TickerResultadosCantidad": tickerCantidad,
"ConcejalesResultadosCantidad": concejalesCantidad
});
await queryClient.invalidateQueries({ queryKey: ['composicionCongreso'] });
await queryClient.invalidateQueries({ queryKey: ['bancadasDetalle'] });
alert('Configuración guardada.');
} catch {
alert('Error al guardar.');
}
};
if (loading) return <div className="admin-module"><p>Cargando...</p></div>;
if (error) return <div className="admin-module"><p style={{ color: 'red' }}>{error}</p></div>;
return (
<div className="admin-module">
<h3>Configuración General de Visualización</h3>
<div className="form-group">
<label>
<input
type="checkbox"
checked={modoOficialActivo}
onChange={e => setModoOficialActivo(e.target.checked)}
/>
**Activar Modo "Resultados Oficiales"**
</label>
<p style={{ fontSize: '0.8rem', color: '#666' }}>
Si está activo, el sitio público mostrará la composición de bancas y los ocupantes definidos manualmente en este panel. Si está inactivo, mostrará la proyección en tiempo real de la elección.
</p>
</div>
<div style={{ marginTop: '1rem', paddingBottom: '1rem', borderBottom: '1px solid #eee' }}>
<label
htmlFor="presidencia-senado"
style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}
>
Presidencia Cámara de Senadores (Vicegobernador)
</label>
<select
id="presidencia-senado"
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>
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
Seleccione el partido político al que pertenece el Vicegobernador. El asiento presidencial del Senado se pintará con el color de este partido.
</p>
</div>
<div style={{ marginTop: '1rem', borderBottom: '2px solid #eee' }}>
<p style={{ fontWeight: 'bold', margin: 0 }}>
Presidencia Cámara de Diputados
</p>
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
Esta banca se asigna y colorea automáticamente según la agrupación política con la mayoría de bancas totales en la cámara.
</p>
</div>
<div className="form-group" style={{ marginTop: '2rem' }}>
<label htmlFor="ticker-cantidad">Cantidad en Ticker (Dip/Sen) (Sumar 1 para "Otros")</label>
<input id="ticker-cantidad" type="number" value={tickerCantidad} onChange={e => setTickerCantidad(e.target.value)} />
</div>
<div className="form-group" style={{ marginTop: '2rem' }}>
<label htmlFor="concejales-cantidad">Cantidad en Widget Concejales (Sumar 1 para "Otros")</label>
<input
id="concejales-cantidad"
type="number"
value={concejalesCantidad}
onChange={e => setConcejalesCantidad(e.target.value)}
/>
</div>
<button onClick={handleSave} style={{ marginTop: '1.5rem' }}>
Guardar Configuración
</button>
</div>
);
};

View File

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

View File

@@ -0,0 +1,90 @@
// 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 { 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={{ /* ... */ }}>
<h1>Panel de Administración Electoral</h1>
<button onClick={logout}>Cerrar Sesión</button>
</header>
<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>
</main>
</div>
);
}

View File

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

View File

@@ -0,0 +1,54 @@
// src/components/LoginPage.tsx
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useQueryClient } from '@tanstack/react-query';
export const LoginPage = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const queryClient = useQueryClient();
const { login } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const success = await login({ username, password });
if (!success) {
setError('Usuario o contraseña incorrectos.');
} else {
// Si el login es exitoso, invalidamos todo para empezar de cero
await queryClient.invalidateQueries();
}
};
return (
<div style={{ /* Estilos simples para centrar */ }}>
<h2>Panel de Administración</h2>
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Usuario:</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Contraseña:</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button type="submit">Ingresar</button>
</form>
</div>
);
};

View File

@@ -0,0 +1,121 @@
// src/components/LogoOverridesManager.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import Select from 'react-select';
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';
const ELECCION_OPTIONS = [
{ value: 0, label: 'General (Todas las elecciones)' },
{ value: 2, label: 'Elecciones Nacionales' },
{ value: 1, label: 'Elecciones Provinciales' }
];
const AMBITO_LEVEL_OPTIONS = [
{ value: 'general', label: 'General (Toda la elección)' },
{ value: 'provincia', label: 'Por Provincia' },
{ value: 'municipio', label: 'Por Municipio' }
];
export const LogoOverridesManager = () => {
const queryClient = useQueryClient();
const [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 [logoUrl, setLogoUrl] = 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: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({
queryKey: ['allLogos'],
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
});
const categoriaOptions = useMemo(() => {
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
}, [selectedEleccion]);
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return null;
};
const currentLogo = useMemo(() => {
if (!selectedAgrupacion || !selectedCategoria) return '';
const ambitoId = getAmbitoId();
return logos.find(l =>
l.eleccionId === selectedEleccion.value &&
l.ambitoGeograficoId === ambitoId &&
l.agrupacionPoliticaId === selectedAgrupacion.id &&
l.categoriaId === selectedCategoria.value
)?.logoUrl || '';
}, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
const handleSave = async () => {
if (!selectedAgrupacion || !selectedCategoria) {
alert("Por favor, seleccione una agrupación y una categoría.");
return;
}
const newLogoEntry: LogoAgrupacionCategoria = {
id: 0,
eleccionId: selectedEleccion.value,
agrupacionPoliticaId: selectedAgrupacion.id,
categoriaId: selectedCategoria.value,
ambitoGeograficoId: getAmbitoId(),
logoUrl: logoUrl.trim() || null
};
try {
await updateLogos([newLogoEntry]);
queryClient.invalidateQueries({ queryKey: ['allLogos'] });
alert('Override de logo guardado.');
} catch { alert('Error al guardar.'); }
};
return (
<div className="admin-module">
<h3>Overrides de Logos</h3>
<p>Configure una imagen específica para un partido en un contexto determinado.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}>
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} />
<Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." />
<Select
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
getOptionValue={opt => opt.id}
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
value={selectedAgrupacion}
onChange={setSelectedAgrupacion}
placeholder="Seleccione Agrupación..."
/>
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
) : <div />}
{selectedAmbitoLevel.value === 'municipio' ? (
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
) : <div />}
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
<div style={{ flex: 1 }}>
<label>URL del Logo Específico</label>
<input type="text" value={logoUrl} onChange={e => setLogoUrl(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,47 @@
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
width: 90%;
max-width: 500px;
position: relative;
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
border: none;
background: none;
font-size: 1.5rem;
cursor: pointer;
}
.modal-content h4 { margin-top: 0; }
.modal-content h5 { margin-top: 0; color: #666; }
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 8px;
box-sizing: border-box;
}
.modal-actions {
text-align: right;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,64 @@
// src/components/OcupantesModal.tsx
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { updateBancada, type UpdateBancadaData } from '../services/apiService';
import type { Bancada } from '../types';
import './OcupantesModal.css'; // Crearemos este archivo
interface Props {
bancada: Bancada;
onClose: () => void;
activeTab: 'diputados' | 'senadores';
}
export const OcupantesModal = ({ bancada, onClose, activeTab }: Props) => {
const queryClient = useQueryClient();
const [nombre, setNombre] = useState(bancada.ocupante?.nombreOcupante || '');
const [fotoUrl, setFotoUrl] = useState(bancada.ocupante?.fotoUrl || '');
const [periodo, setPeriodo] = useState(bancada.ocupante?.periodo || '');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const payload: UpdateBancadaData = {
agrupacionPoliticaId: bancada.agrupacionPoliticaId,
nombreOcupante: nombre || null,
fotoUrl: fotoUrl || null,
periodo: periodo || null,
};
try {
await updateBancada(bancada.id, payload);
// Invalida la query para que la tabla principal se actualice
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab] });
onClose();
} catch (err) {
alert("Error al guardar el ocupante.");
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>&times;</button>
<h4>Ocupante de la Banca #{bancada.id}</h4>
<h5>{bancada.agrupacionPolitica?.nombre || 'Banca Vacante'}</h5>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="nombre">Nombre Completo</label>
<input id="nombre" type="text" value={nombre} onChange={e => setNombre(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="fotoUrl">URL de la Foto</label>
<input id="fotoUrl" type="text" value={fotoUrl} onChange={e => setFotoUrl(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="periodo">Período (ej. 2023-2027)</label>
<input id="periodo" type="text" value={periodo} onChange={e => setPeriodo(e.target.value)} />
</div>
<div className="modal-actions">
<button type="submit">Guardar Cambios</button>
</div>
</form>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
// src/components/SortableItem.tsx
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export function SortableItem(props: { id: string, children: React.ReactNode }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: props.id });
// La única propiedad de estilo que necesitamos en línea es la que calcula dnd-kit
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Añadimos la clase CSS que creamos
return (
<li ref={setNodeRef} style={style} {...attributes} {...listeners} className="sortable-item">
{props.children}
</li>
);
}

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: 2, label: 'Senadores Nacionales' },
{ value: 3, label: 'Diputados Nacionales' },
// Provinciales
{ value: 5, label: 'Senadores Provinciales' },
{ value: 6, label: 'Diputados Provinciales' },
{ value: 7, label: 'Concejales' },
];
export const CATEGORIAS_NACIONALES_OPTIONS = [
{ value: 2, label: 'Senadores Nacionales' },
{ value: 3, label: 'Diputados Nacionales' },
];
export const CATEGORIAS_PROVINCIALES_OPTIONS = [
{ value: 5, label: 'Senadores Provinciales' },
{ value: 6, label: 'Diputados Provinciales' },
{ value: 7, label: 'Concejales' },
];

View File

@@ -0,0 +1,71 @@
// src/context/AuthContext.tsx
import { createContext, useState, useContext, type ReactNode, useEffect } from 'react';
import { loginUser } from '../services/apiService'; // Importaremos esta función
import type { LoginCredentials } from '../services/apiService'; // y este tipo
import { subscribeToLogout } from './authUtils';
interface AuthContextType {
isAuthenticated: boolean;
token: string | null;
login: (credentials: LoginCredentials) => Promise<boolean>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
// Al cargar la app, revisamos si ya hay un token guardado
const storedToken = localStorage.getItem('admin-jwt-token');
if (storedToken) {
setToken(storedToken);
}
}, []);
const logout = () => {
localStorage.removeItem('admin-jwt-token');
setToken(null);
};
useEffect(() => {
// Nos suscribimos al evento de logout global.
const handleLogout = () => logout();
subscribeToLogout(handleLogout);
}, []); // Se ejecuta solo una vez
const login = async (credentials: LoginCredentials): Promise<boolean> => {
try {
const receivedToken = await loginUser(credentials);
if (receivedToken) {
localStorage.setItem('admin-jwt-token', receivedToken);
setToken(receivedToken);
return true;
}
return false;
} catch (error) {
console.error("Login failed:", error);
// Asegurarse de que el usuario esté deslogueado si falla
logout();
return false;
}
};
const value = {
isAuthenticated: !!token,
token,
login,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,14 @@
// src/context/authUtils.ts
// Creamos un "emisor de eventos" muy simple.
const events = new EventTarget();
// La API escuchará este evento personalizado.
export function subscribeToLogout(callback: () => void) {
events.addEventListener('logout', callback);
}
// El interceptor llamará a esta función para disparar el evento.
export function triggerLogout() {
events.dispatchEvent(new Event('logout'));
}

View File

@@ -0,0 +1,22 @@
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { AuthProvider } from './context/AuthContext.tsx';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// 1. Crear una instancia del cliente de query
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
{/* 2. Envolver la aplicación con el proveedor */}
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
</AuthProvider>
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,208 @@
// src/services/apiService.ts
import axios from 'axios';
import { triggerLogout } from '../context/authUtils';
import type {
CandidatoOverride, AgrupacionPolitica,
UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria,
MunicipioSimple, BancaPrevia, ProvinciaSimple
} from '../types';
/**
* 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,
});
// Cliente de API para endpoints públicos (no envía token)
const apiClient = axios.create({
baseURL: API_URL_BASE,
headers: { 'Content-Type': 'application/json' },
});
// --- INTERCEPTORES (Solo para el cliente de admin) ---
adminApiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('admin-jwt-token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
adminApiClient.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error) && error.response?.status === 401) {
console.log("Token expirado o inválido. Deslogueando...");
triggerLogout();
}
return Promise.reject(error);
}
);
// --- 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
export interface LoginCredentials { username: string; password: string; }
export const loginUser = async (credentials: LoginCredentials): Promise<string | null> => {
try {
const response = await axios.post(`${AUTH_API_URL}/login`, credentials);
return response.data.token;
} catch (error) {
console.error("Error during login request:", error);
throw error;
}
};
// 2. Agrupaciones
export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => {
const response = await adminApiClient.get('/agrupaciones');
return response.data;
};
export const updateAgrupacion = async (id: string, data: UpdateAgrupacionData): Promise<void> => {
await adminApiClient.put(`/agrupaciones/${id}`, data);
};
// 3. Ordenamiento de Agrupaciones
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
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;
};
export interface UpdateBancadaData {
agrupacionPoliticaId: string | null;
nombreOcupante: string | null;
fotoUrl: string | null;
periodo: string | null;
}
export const updateBancada = async (bancadaId: number, data: UpdateBancadaData): Promise<void> => {
await adminApiClient.put(`/bancadas/${bancadaId}`, data);
};
// 5. Configuración General
export type ConfiguracionResponse = Record<string, string>;
export const getConfiguracion = async (): Promise<ConfiguracionResponse> => {
const response = await adminApiClient.get('/configuracion');
return response.data;
};
export const updateConfiguracion = async (data: Record<string, string>): Promise<void> => {
await adminApiClient.put('/configuracion', data);
};
// 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[]> => {
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;
};
export interface CreateAgrupacionData {
nombre: string;
nombreCorto: string | null;
color: string | null;
}
// Servicio para crear una nueva agrupación
export const createAgrupacion = async (data: CreateAgrupacionData): Promise<AgrupacionPolitica> => {
const response = await adminApiClient.post('/agrupaciones', data);
return response.data;
};

View File

@@ -0,0 +1,76 @@
// src/types/index.ts
export interface AgrupacionPolitica {
id: string;
idTelegrama: string;
nombre: string;
nombreCorto: string | null;
color: string | null;
ordenDiputados: number | null;
ordenSenadores: number | null;
ordenDiputadosNacionales: number | null;
ordenSenadoresNacionales: number | null;
}
export interface UpdateAgrupacionData {
nombreCorto: string | null;
color: string | null;
}
export const TipoCamara = {
Diputados: 0,
Senadores: 1,
} as const;
export type TipoCamaraValue = typeof TipoCamara[keyof typeof TipoCamara];
export interface OcupanteBanca {
id: number;
bancadaId: number;
nombreOcupante: string;
fotoUrl: string | null;
periodo: string | null;
}
export interface Bancada {
id: number;
eleccionId: number; // Clave para diferenciar provinciales de nacionales
camara: TipoCamaraValue;
numeroBanca: number;
agrupacionPoliticaId: string | null;
agrupacionPolitica: AgrupacionPolitica | null;
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 | null;
logoUrl: string | null;
ambitoGeograficoId: number | null;
}
export interface MunicipioSimple { id: string; nombre: string; }
export interface ProvinciaSimple { id: string; nombre: string; }
export interface CandidatoOverride {
id: number;
eleccionId: number; // Clave para diferenciar
agrupacionPoliticaId: string;
categoriaId: number;
ambitoGeograficoId: number | null;
nombreCandidato: string | null;
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -0,0 +1,20 @@
#Dockerfile
# --- Etapa 1: Build ---
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# --- Etapa 2: Producción ---
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
# Copia la configuración de Nginx al contenedor del frontend
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
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,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 - Dev Showcase</title>
</head>
<body>
<div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -10,14 +10,36 @@
"preview": "vite preview"
},
"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-dom": "^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",
"swiper": "^12.0.2",
"topojson-client": "^3.1.0",
"vite-plugin-svgr": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/geojson": "^7946.0.16",
"@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,138 @@
// frontend/public/bootstrap.js
(function () {
// El dominio donde se alojan los widgets
const WIDGETS_HOST = 'https://elecciones2025.eldia.com';
// Estado interno para evitar recargas y re-fetch innecesarios
const __state = {
assetsLoaded: false,
manifest: null,
};
// Función para cargar dinámicamente un script (evita duplicados)
function loadScript(src) {
return new Promise((resolve, reject) => {
if ([...document.scripts].some(s => s.src === src)) return resolve();
const script = document.createElement('script');
script.type = 'module';
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Función para cargar dinámicamente una hoja de estilos (evita duplicados)
function loadCSS(href) {
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(l => l.href === href)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// Carga (una sola vez) JS/CSS definidos por el manifest
async function ensureAssetsFromManifest() {
if (__state.assetsLoaded) return;
// 1) Obtener el manifest.json (cache: no-store por si hay deploys frecuentes)
if (!__state.manifest) {
const response = await fetch(`${WIDGETS_HOST}/manifest.json`, { cache: 'no-store' });
if (!response.ok) throw new Error('No se pudo cargar el manifest de los widgets.');
__state.manifest = await response.json();
}
// 2) Encontrar el entry principal (isEntry=true)
const entryKey = Object.keys(__state.manifest).find(key => __state.manifest[key].isEntry);
if (!entryKey) throw new Error('No se encontró el punto de entrada en el manifest.');
const entry = __state.manifest[entryKey];
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
// 3) Cargar el CSS si existe (una sola vez)
if (entry.css && entry.css.length > 0) {
entry.css.forEach(cssFile => loadCSS(`${WIDGETS_HOST}/${cssFile}`));
}
// 4) Cargar el JS principal (una sola vez)
await loadScript(jsUrl);
__state.assetsLoaded = true;
}
// Render: busca contenedores y llama a la API global del widget
function renderWidgetsOnPage() {
if (!(window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function')) {
// La librería aún no expuso la API (puede ocurrir en primeros ms tras cargar)
return;
}
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
if (widgetContainers.length === 0) {
// En algunas rutas no habrá widgets: no es error.
return;
}
widgetContainers.forEach(container => {
window.EleccionesWidgets.render(container, container.dataset);
});
}
// Función principal (re-usable) para inicializar y renderizar
async function initWidgets() {
try {
await ensureAssetsFromManifest();
renderWidgetsOnPage();
} catch (error) {
console.error('Error al inicializar los widgets de elecciones:', error);
}
}
// Exponer para invocación manual (por ejemplo, en hooks del router)
window.__eleccionesInit = initWidgets;
// Primer render en carga inicial
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initWidgets);
} else {
initWidgets();
}
// --- Reinvocar en cada navegación de SPA ---
function dispatchLocationChange() {
window.dispatchEvent(new Event('locationchange'));
}
['pushState', 'replaceState'].forEach(method => {
const orig = history[method];
history[method] = function () {
const ret = orig.apply(this, arguments);
dispatchLocationChange();
return ret;
};
});
window.addEventListener('popstate', dispatchLocationChange);
let navDebounce = null;
window.addEventListener('locationchange', () => {
clearTimeout(navDebounce);
navDebounce = setTimeout(() => {
initWidgets();
}, 0);
});
// --- (Opcional) Re-render si aparecen contenedores luego del montaje de la vista ---
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
const added = [...m.addedNodes].some(n =>
n.nodeType === 1 &&
(n.matches?.('[data-elecciones-widget]') || n.querySelector?.('[data-elecciones-widget]'))
);
if (added) { renderWidgetsOnPage(); break; }
}
}
});
mo.observe(document.body, { childList: true, subtree: true });
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

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

View File

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

After

Width:  |  Height:  |  Size: 821 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

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