Compare commits
	
		
			104 Commits
		
	
	
		
			9d5c2086c5
			...
			Legislativ
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ed5b78e6c8 | |||
| a985cbfd7c | |||
| 3b0eee25e6 | |||
| 67634ae947 | |||
| 5a8bee52d5 | |||
| 3750d1a56d | |||
| 7d2922aaeb | |||
| 3a8f64bf85 | |||
| 64dc7ef440 | |||
| 843c0f7258 | |||
| 326b6b3c59 | |||
| 143759a929 | |||
| 153c0f92da | |||
| 6309003536 | |||
| f4d4cd173f | |||
| 2fac830528 | |||
| d091d91f89 | |||
| 9e0cf30294 | |||
| d5168e1d17 | |||
| 2169b57bc6 | |||
| 865cc488df | |||
| 70c8ce54de | |||
| c105106f3b | |||
| f497c89ffa | |||
| 565128321c | |||
| 2b47d8e20d | |||
| fc97e29f13 | |||
| 7f49362e55 | |||
| 50d3c6bce9 | |||
| ad30d4263d | |||
| a49fc80fd9 | |||
| 475b886d9a | |||
| fa92d9638c | |||
| f384a640f3 | |||
| 31d434b2aa | |||
| 1d9ed05446 | |||
| d52c452009 | |||
| 6354791f28 | |||
| a495ab67ef | |||
| c48cc1bec5 | |||
| 12acd61f2b | |||
| d78a02a0eb | |||
| 479c2c60f2 | |||
| 0ce5e2e2c9 | |||
| 2db20969a1 | |||
| f41b4eaa1c | |||
| ff6c2f29e7 | |||
| 5d0f2460f9 | |||
| 8ce48b3a46 | |||
| 29f8146b32 | |||
| 83047721a3 | |||
| c23e6f136e | |||
| fdeacda683 | |||
| ef3967fcd6 | |||
| cb329da04e | |||
| a5638e3e91 | |||
| 46b2d2cfe6 | |||
| a7e231dfb6 | |||
| 25def576e9 | |||
| 025f051839 | |||
| 82371bd159 | |||
| eee21b8d52 | |||
| 675c1f83a5 | |||
| 6ebe62ef87 | |||
| cf571cbc14 | |||
| 7bc96fec2b | |||
| 0c1cca64a8 | |||
| 3381dd1778 | |||
| 57fe26afa9 | |||
| 6a508f468b | |||
| 18439c40d7 | |||
| b8e6d33afa | |||
| 36a004a0b0 | |||
| a81f1fe894 | |||
| 32e85b9b9d | |||
| 6732a0e826 | |||
| 9393d2bc05 | |||
| 6ac6034255 | |||
| f961f9d8e7 | |||
| da581d9714 | |||
| 271a86b632 | |||
| 12860f2406 | |||
| 608ae655be | |||
| 3b8c6bf754 | |||
| 1ed9a49a53 | |||
| 55954e18a7 | |||
| 0d33db9e6d | |||
| 4a6318c18a | |||
| 8192185bc5 | |||
| 13c6accd15 | |||
| 303a469c57 | |||
| 8bb8a5dede | |||
| f88436def6 | |||
| e5ecdc301e | |||
| 5de9d6729c | |||
| 18e6e8d3c0 | |||
| 4fb2b87aa1 | |||
| 1a6f7dd5a3 | |||
| 43a967eac2 | |||
| a2bf221194 | |||
| c967da919a | |||
| 19b37f7320 | |||
| 7e1e487e83 | |||
| 3d685fba1e | 
							
								
								
									
										24
									
								
								Elecciones-Web/frontend-admin/.gitignore
									
									
									
									
										vendored
									
									
										Normal 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? | ||||
							
								
								
									
										18
									
								
								Elecciones-Web/frontend-admin/Dockerfile
									
									
									
									
									
										Normal 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 | ||||
							
								
								
									
										69
									
								
								Elecciones-Web/frontend-admin/README.md
									
									
									
									
									
										Normal 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... | ||||
|     }, | ||||
|   }, | ||||
| ]) | ||||
| ``` | ||||
							
								
								
									
										23
									
								
								Elecciones-Web/frontend-admin/eslint.config.js
									
									
									
									
									
										Normal 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, | ||||
|     }, | ||||
|   }, | ||||
| ]) | ||||
							
								
								
									
										18
									
								
								Elecciones-Web/frontend-admin/frontend.nginx.conf
									
									
									
									
									
										Normal 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"; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								Elecciones-Web/frontend-admin/index.html
									
									
									
									
									
										Normal 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> | ||||
							
								
								
									
										4201
									
								
								Elecciones-Web/frontend-admin/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										35
									
								
								Elecciones-Web/frontend-admin/package.json
									
									
									
									
									
										Normal 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" | ||||
|   } | ||||
| } | ||||
							
								
								
									
										6
									
								
								Elecciones-Web/frontend-admin/public/eldia.svg
									
									
									
									
									
										Normal 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 | 
							
								
								
									
										42
									
								
								Elecciones-Web/frontend-admin/src/App.css
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										17
									
								
								Elecciones-Web/frontend-admin/src/App.tsx
									
									
									
									
									
										Normal 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; | ||||
| @@ -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; } | ||||
| @@ -0,0 +1,149 @@ | ||||
| // 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 './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()), | ||||
|     }); | ||||
|  | ||||
|     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.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> | ||||
|                 </> | ||||
|             )} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,117 @@ | ||||
| // src/components/BancasNacionalesManager.tsx | ||||
| import { useState } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService'; | ||||
| import type { Bancada, AgrupacionPolitica } from '../types'; | ||||
| import { OcupantesModal } from './OcupantesModal'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
| const camaras = ['diputados', 'senadores'] as const; | ||||
|  | ||||
| export const BancasNacionalesManager = () => { | ||||
|   const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados'); | ||||
|   const [modalVisible, setModalVisible] = useState(false); | ||||
|   const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null); | ||||
|   const queryClient = useQueryClient(); | ||||
|  | ||||
|   const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ | ||||
|     queryKey: ['agrupaciones'], | ||||
|     queryFn: getAgrupaciones | ||||
|   }); | ||||
|  | ||||
|   const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({ | ||||
|     queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL], | ||||
|     queryFn: () => getBancadas(activeTab, ELECCION_ID_NACIONAL), | ||||
|   }); | ||||
|  | ||||
|   const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => { | ||||
|     const bancadaActual = bancadas.find(b => b.id === bancadaId); | ||||
|     if (!bancadaActual) return; | ||||
|  | ||||
|     const payload: UpdateBancadaData = { | ||||
|       agrupacionPoliticaId: nuevaAgrupacionId, | ||||
|       nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null, | ||||
|       fotoUrl: nuevaAgrupacionId ? (bancadaActual.ocupante?.fotoUrl ?? null) : null, | ||||
|       periodo: nuevaAgrupacionId ? (bancadaActual.ocupante?.periodo ?? null) : null, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       await updateBancada(bancadaId, payload); | ||||
|       queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL] }); | ||||
|     } catch (err) { | ||||
|       alert("Error al guardar el cambio de agrupación."); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleOpenModal = (bancada: Bancada) => { | ||||
|     setBancadaSeleccionada(bancada); | ||||
|     setModalVisible(true); | ||||
|   }; | ||||
|  | ||||
|   if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas nacionales.</p>; | ||||
|  | ||||
|   return ( | ||||
|     <div className="admin-module"> | ||||
|       <h3>Gestión de Bancas (Nacionales)</h3> | ||||
|       <p>Asigne partidos y ocupantes a las bancas del Congreso de la Nación.</p> | ||||
|  | ||||
|       <div className="chamber-tabs"> | ||||
|         {camaras.map(camara => ( | ||||
|           <button | ||||
|             key={camara} | ||||
|             className={activeTab === camara ? 'active' : ''} | ||||
|             onClick={() => setActiveTab(camara)} | ||||
|           > | ||||
|             {camara === 'diputados' ? 'Diputados Nacionales (257)' : 'Senadores Nacionales (72)'} | ||||
|           </button> | ||||
|         ))} | ||||
|       </div> | ||||
|  | ||||
|       {isLoading ? <p>Cargando bancas...</p> : ( | ||||
|         <div className="table-container"> | ||||
|           <table> | ||||
|             <thead> | ||||
|               <tr> | ||||
|                 <th style={{ width: '15%' }}>Banca #</th> | ||||
|                 <th style={{ width: '35%' }}>Partido Asignado</th> | ||||
|                 <th style={{ width: '30%' }}>Ocupante Actual</th> | ||||
|                 <th style={{ width: '20%' }}>Acciones</th> | ||||
|               </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|               {bancadas.map((bancada) => ( | ||||
|                 <tr key={bancada.id}> | ||||
|                   <td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td> | ||||
|                   <td> | ||||
|                     <select | ||||
|                       value={bancada.agrupacionPoliticaId || ''} | ||||
|                       onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)} | ||||
|                     > | ||||
|                       <option value="">-- Vacante --</option> | ||||
|                       {agrupaciones.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)} | ||||
|                     </select> | ||||
|                   </td> | ||||
|                   <td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td> | ||||
|                   <td> | ||||
|                     <button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}> | ||||
|                       Editar Ocupante | ||||
|                     </button> | ||||
|                   </td> | ||||
|                 </tr> | ||||
|               ))} | ||||
|             </tbody> | ||||
|           </table> | ||||
|         </div> | ||||
|       )} | ||||
|  | ||||
|       {modalVisible && bancadaSeleccionada && ( | ||||
|         <OcupantesModal | ||||
|           bancada={bancadaSeleccionada} | ||||
|           onClose={() => setModalVisible(false)} | ||||
|           activeTab={activeTab} | ||||
|         /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,125 @@ | ||||
| // src/components/BancasPreviasManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import { getBancasPrevias, updateBancasPrevias, getAgrupaciones } from '../services/apiService'; | ||||
| import type { BancaPrevia, AgrupacionPolitica } from '../types'; | ||||
| import { TipoCamara } from '../types'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const BancasPreviasManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const [editedBancas, setEditedBancas] = useState<Record<string, Partial<BancaPrevia>>>({}); | ||||
|  | ||||
|     const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|         queryKey: ['agrupaciones'], | ||||
|         queryFn: getAgrupaciones, | ||||
|     }); | ||||
|  | ||||
|     const { data: bancasPrevias = [], isLoading: isLoadingBancas } = useQuery<BancaPrevia[]>({ | ||||
|         queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL], | ||||
|         queryFn: () => getBancasPrevias(ELECCION_ID_NACIONAL), | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (agrupaciones.length > 0) { | ||||
|             const initialData: Record<string, Partial<BancaPrevia>> = {}; | ||||
|             agrupaciones.forEach(agrupacion => { | ||||
|                 // Para Diputados | ||||
|                 const keyDip = `${agrupacion.id}-${TipoCamara.Diputados}`; | ||||
|                 const existingDip = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Diputados); | ||||
|                 initialData[keyDip] = { cantidad: existingDip?.cantidad || 0 }; | ||||
|  | ||||
|                 // Para Senadores | ||||
|                 const keySen = `${agrupacion.id}-${TipoCamara.Senadores}`; | ||||
|                 const existingSen = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Senadores); | ||||
|                 initialData[keySen] = { cantidad: existingSen?.cantidad || 0 }; | ||||
|             }); | ||||
|             setEditedBancas(initialData); | ||||
|         } | ||||
|     }, [agrupaciones, bancasPrevias]); | ||||
|  | ||||
|     const handleInputChange = (agrupacionId: string, camara: typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, value: string) => { | ||||
|         const key = `${agrupacionId}-${camara}`; | ||||
|         const cantidad = parseInt(value, 10); | ||||
|         setEditedBancas(prev => ({ | ||||
|             ...prev, | ||||
|             [key]: { ...prev[key], cantidad: isNaN(cantidad) ? 0 : cantidad } | ||||
|         })); | ||||
|     }; | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         const payload: BancaPrevia[] = Object.entries(editedBancas) | ||||
|             .map(([key, value]) => { | ||||
|                 const [agrupacionPoliticaId, camara] = key.split('-'); | ||||
|                 return { | ||||
|                     id: 0, | ||||
|                     eleccionId: ELECCION_ID_NACIONAL, | ||||
|                     agrupacionPoliticaId, | ||||
|                     camara: parseInt(camara) as typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, | ||||
|                     cantidad: value.cantidad || 0, | ||||
|                 }; | ||||
|             }) | ||||
|             .filter(b => b.cantidad > 0); | ||||
|  | ||||
|         try { | ||||
|             await updateBancasPrevias(ELECCION_ID_NACIONAL, payload); | ||||
|             queryClient.invalidateQueries({ queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL] }); | ||||
|             alert('Bancas previas guardadas con éxito.'); | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             alert('Error al guardar las bancas previas.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const totalDiputados = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Diputados}`) ? sum + (value.cantidad || 0) : sum, 0); | ||||
|     const totalSenadores = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Senadores}`) ? sum + (value.cantidad || 0) : sum, 0); | ||||
|  | ||||
|     const isLoading = isLoadingAgrupaciones || isLoadingBancas; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Gestión de Bancas Previas (Composición Nacional)</h3> | ||||
|             <p>Define cuántas bancas retiene cada partido antes de la elección. Estos son los escaños que **no** están en juego.</p> | ||||
|             {isLoading ? <p>Cargando...</p> : ( | ||||
|                 <> | ||||
|                     <div className="table-container"> | ||||
|                         <table> | ||||
|                             <thead> | ||||
|                                 <tr> | ||||
|                                     <th>Agrupación Política</th> | ||||
|                                     <th>Bancas Previas Diputados (Total: {totalDiputados} / 130)</th> | ||||
|                                     <th>Bancas Previas Senadores (Total: {totalSenadores} / 48)</th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|                             <tbody> | ||||
|                                 {agrupaciones.map(agrupacion => ( | ||||
|                                     <tr key={agrupacion.id}> | ||||
|                                         <td>{agrupacion.nombre}</td> | ||||
|                                         <td> | ||||
|                                             <input | ||||
|                                                 type="number" | ||||
|                                                 min="0" | ||||
|                                                 value={editedBancas[`${agrupacion.id}-${TipoCamara.Diputados}`]?.cantidad || 0} | ||||
|                                                 onChange={e => handleInputChange(agrupacion.id, TipoCamara.Diputados, e.target.value)} | ||||
|                                             /> | ||||
|                                         </td> | ||||
|                                         <td> | ||||
|                                             <input | ||||
|                                                 type="number" | ||||
|                                                 min="0" | ||||
|                                                 value={editedBancas[`${agrupacion.id}-${TipoCamara.Senadores}`]?.cantidad || 0} | ||||
|                                                 onChange={e => handleInputChange(agrupacion.id, TipoCamara.Senadores, e.target.value)} | ||||
|                                             /> | ||||
|                                         </td> | ||||
|                                     </tr> | ||||
|                                 ))} | ||||
|                             </tbody> | ||||
|                         </table> | ||||
|                     </div> | ||||
|                     <button onClick={handleSave} style={{ marginTop: '1rem' }}>Guardar Bancas Previas</button> | ||||
|                 </> | ||||
|             )} | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -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.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> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,106 @@ | ||||
| // src/components/CandidatoOverridesManager.tsx | ||||
| import { useState, useMemo, useEffect } from 'react'; | ||||
| import { useQuery, useQueryClient } from '@tanstack/react-query'; | ||||
| import Select from 'react-select'; | ||||
| import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService'; | ||||
| import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types'; | ||||
| import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias'; | ||||
|  | ||||
| const ELECCION_OPTIONS = [     | ||||
|     { value: 2, label: 'Elecciones Nacionales' }, | ||||
|     { value: 1, label: 'Elecciones Provinciales' } | ||||
| ]; | ||||
|  | ||||
| const AMBITO_LEVEL_OPTIONS = [ | ||||
|     { value: 'general', label: 'General (Toda la elección)' }, | ||||
|     { value: 'provincia', label: 'Por Provincia' }, | ||||
|     { value: 'municipio', label: 'Por Municipio' } | ||||
| ]; | ||||
|  | ||||
| export const CandidatoOverridesManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); | ||||
|     const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]); | ||||
|     const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null); | ||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null); | ||||
|     const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||
|     const [nombreCandidato, setNombreCandidato] = useState(''); | ||||
|  | ||||
|     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||
|     const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ | ||||
|         queryKey: ['candidatos', selectedEleccion.value], | ||||
|         queryFn: () => getCandidatos(selectedEleccion.value), | ||||
|     }); | ||||
|  | ||||
|     const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS; | ||||
|  | ||||
|     const getAmbitoId = () => { | ||||
|         if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id); | ||||
|         if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id); | ||||
|         return null; | ||||
|     }; | ||||
|  | ||||
|     const currentCandidato = useMemo(() => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||
|         const ambitoId = getAmbitoId(); | ||||
|         return candidatos.find(c => | ||||
|             c.ambitoGeograficoId === ambitoId && | ||||
|             c.agrupacionPoliticaId === selectedAgrupacion.id && | ||||
|             c.categoriaId === selectedCategoria.value | ||||
|         )?.nombreCandidato || ''; | ||||
|     }, [candidatos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||
|  | ||||
|     useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return; | ||||
|         const newCandidatoEntry: CandidatoOverride = { | ||||
|             id: 0, | ||||
|             eleccionId: selectedEleccion.value, | ||||
|             agrupacionPoliticaId: selectedAgrupacion.id, | ||||
|             categoriaId: selectedCategoria.value, | ||||
|             ambitoGeograficoId: getAmbitoId(), | ||||
|             nombreCandidato: nombreCandidato || null | ||||
|         }; | ||||
|         try { | ||||
|             await updateCandidatos([newCandidatoEntry]); | ||||
|             queryClient.invalidateQueries({ queryKey: ['candidatos', selectedEleccion.value] }); | ||||
|             alert('Override de candidato guardado.'); | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             alert('Error al guardar el override del candidato.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Overrides de Nombres de Candidatos</h3> | ||||
|             <p>Configure un nombre de candidato específico para un partido en un contexto determinado.</p> | ||||
|             <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}> | ||||
|                 <Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} /> | ||||
|                 <Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." isDisabled={!selectedEleccion} /> | ||||
|                 <Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedAgrupacion} onChange={setSelectedAgrupacion} placeholder="Seleccione Agrupación..." /> | ||||
|                 <Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} /> | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." /> | ||||
|                 ) : <div />} | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} /> | ||||
|                 ) : <div />} | ||||
|             </div> | ||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>Nombre del Candidato</label> | ||||
|                     <input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} /> | ||||
|                 </div> | ||||
|                 <button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button> | ||||
|             </div> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -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> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,110 @@ | ||||
| // src/components/ConfiguracionNacional.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import { getAgrupaciones, getConfiguracion, updateConfiguracion } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| export const ConfiguracionNacional = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|     const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]); | ||||
|     const [loading, setLoading] = useState(true); | ||||
|      | ||||
|     const [presidenciaDiputadosId, setPresidenciaDiputadosId] = useState<string>(''); | ||||
|     const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>(''); | ||||
|     const [modoOficialActivo, setModoOficialActivo] = useState(false); | ||||
|     const [diputadosTipoBanca, setDiputadosTipoBanca] = useState<'ganada' | 'previa'>('ganada'); | ||||
|     // El estado para el tipo de banca del senado ya no es necesario para la UI,  | ||||
|     // pero lo mantenemos para no romper el handleSave. | ||||
|     const [senadoTipoBanca, setSenadoTipoBanca] = useState<'ganada' | 'previa'>('ganada'); | ||||
|  | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const loadInitialData = async () => { | ||||
|             try { | ||||
|                 setLoading(true); | ||||
|                 const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]); | ||||
|                 setAgrupaciones(agrupacionesData); | ||||
|                 setPresidenciaDiputadosId(configData.PresidenciaDiputadosNacional || ''); | ||||
|                 setPresidenciaSenadoId(configData.PresidenciaSenadoNacional || ''); | ||||
|                 setModoOficialActivo(configData.UsarDatosOficialesNacionales === 'true'); | ||||
|                 setDiputadosTipoBanca(configData.PresidenciaDiputadosNacional_TipoBanca === 'previa' ? 'previa' : 'ganada'); | ||||
|                 setSenadoTipoBanca(configData.PresidenciaSenadoNacional_TipoBanca === 'previa' ? 'previa' : 'ganada'); | ||||
|             } catch (err) { console.error("Error al cargar datos de configuración nacional:", err); }  | ||||
|             finally { setLoading(false); } | ||||
|         }; | ||||
|         loadInitialData(); | ||||
|     }, []); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         try { | ||||
|             await updateConfiguracion({ | ||||
|                 "PresidenciaDiputadosNacional": presidenciaDiputadosId, | ||||
|                 "PresidenciaSenadoNacional": presidenciaSenadoId, | ||||
|                 "UsarDatosOficialesNacionales": modoOficialActivo.toString(), | ||||
|                 "PresidenciaDiputadosNacional_TipoBanca": diputadosTipoBanca, | ||||
|                 // Aunque no se muestre, guardamos el valor para consistencia | ||||
|                 "PresidenciaSenadoNacional_TipoBanca": senadoTipoBanca, | ||||
|             }); | ||||
|             queryClient.invalidateQueries({ queryKey: ['composicionNacional'] }); | ||||
|             alert('Configuración nacional guardada.'); | ||||
|         } catch { alert('Error al guardar.'); } | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <div className="admin-module"><p>Cargando...</p></div>; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Configuración de Widgets Nacionales</h3> | ||||
|             {/*<div className="form-group"> | ||||
|                 <label> | ||||
|                     <input type="checkbox" checked={modoOficialActivo} onChange={e => setModoOficialActivo(e.target.checked)} /> | ||||
|                     **Activar Modo "Resultados Oficiales" para Widgets Nacionales** | ||||
|                 </label> | ||||
|                 <p style={{ fontSize: '0.8rem', color: '#666' }}> | ||||
|                     Si está activo, los widgets nacionales usarán la composición manual de bancas. Si no, usarán la proyección en tiempo real. | ||||
|                 </p> | ||||
|             </div>*/} | ||||
|              | ||||
|             <div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}> | ||||
|                 {/* Columna Diputados */} | ||||
|                 <div style={{ flex: 1, borderRight: '1px solid #ccc', paddingRight: '1rem' }}> | ||||
|                     <label htmlFor="presidencia-diputados-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}> | ||||
|                         Presidencia Cámara de Diputados | ||||
|                     </label> | ||||
|                     <p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}> | ||||
|                         Este escaño es parte de los 257 diputados y se descuenta del total del partido. | ||||
|                     </p> | ||||
|                     <select id="presidencia-diputados-nacional" value={presidenciaDiputadosId} onChange={e => setPresidenciaDiputadosId(e.target.value)} style={{ width: '100%', padding: '8px', marginBottom: '0.5rem' }}> | ||||
|                         <option value="">-- No Asignado --</option> | ||||
|                         {agrupaciones.map(a => (<option key={a.id} value={a.id}>{a.nombre}</option>))} | ||||
|                     </select> | ||||
|                     {presidenciaDiputadosId && ( | ||||
|                         <div> | ||||
|                             <label><input type="radio" value="ganada" checked={diputadosTipoBanca === 'ganada'} onChange={() => setDiputadosTipoBanca('ganada')} /> Descontar de Banca Ganada</label> | ||||
|                             <label style={{marginLeft: '1rem'}}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label> | ||||
|                         </div> | ||||
|                     )}                     | ||||
|                 </div> | ||||
|  | ||||
|                 {/* Columna Senadores */} | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label htmlFor="presidencia-senado-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}> | ||||
|                         Presidencia Senado (Vicepresidente) | ||||
|                     </label> | ||||
|                     <p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}> | ||||
|                         Este escaño es adicional a los 72 senadores y no se descuenta del total del partido. | ||||
|                     </p> | ||||
|                     <select id="presidencia-senado-nacional" value={presidenciaSenadoId} onChange={e => setPresidenciaSenadoId(e.target.value)} style={{ width: '100%', padding: '8px' }}> | ||||
|                         <option value="">-- No Asignado --</option> | ||||
|                         {agrupaciones.map(a => (<option key={a.id} value={a.id}>{a.nombre}</option>))} | ||||
|                     </select>                     | ||||
|                 </div> | ||||
|             </div> | ||||
|              | ||||
|             <button onClick={handleSave} style={{ marginTop: '1.5rem' }}> | ||||
|                 Guardar Configuración | ||||
|             </button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -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> | ||||
|     ); | ||||
| } | ||||
							
								
								
									
										54
									
								
								Elecciones-Web/frontend-admin/src/components/LoginPage.tsx
									
									
									
									
									
										Normal 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> | ||||
|   ); | ||||
| }; | ||||
| @@ -0,0 +1,107 @@ | ||||
| // 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 (Toda la elección)' }, | ||||
|     { value: 2, label: 'Elecciones Nacionales' }, | ||||
|     { value: 1, label: 'Elecciones Provinciales' } | ||||
| ]; | ||||
|  | ||||
| const AMBITO_LEVEL_OPTIONS = [ | ||||
|     { value: 'general', label: 'General (Toda la elección)' }, | ||||
|     { value: 'provincia', label: 'Por Provincia' }, | ||||
|     { value: 'municipio', label: 'Por Municipio' } | ||||
| ]; | ||||
|  | ||||
| export const LogoOverridesManager = () => { | ||||
|     const queryClient = useQueryClient(); | ||||
|  | ||||
|     // --- ESTADOS --- | ||||
|     const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); | ||||
|     const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]); | ||||
|     const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null); | ||||
|     const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null); | ||||
|     const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null); | ||||
|     const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null); | ||||
|     const [logoUrl, setLogoUrl] = useState(''); | ||||
|  | ||||
|     // --- QUERIES --- | ||||
|     const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin }); | ||||
|     const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin }); | ||||
|     const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones }); | ||||
|     const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({ | ||||
|         queryKey: ['logos', selectedEleccion.value], | ||||
|         queryFn: () => getLogos(selectedEleccion.value) | ||||
|     }); | ||||
|  | ||||
|     // --- LÓGICA DE SELECTORES DINÁMICOS --- | ||||
|     const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS; | ||||
|  | ||||
|     const getAmbitoId = () => { | ||||
|         if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id); | ||||
|         if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id); | ||||
|         return 0; | ||||
|     }; | ||||
|  | ||||
|     const currentLogo = useMemo(() => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return ''; | ||||
|         const ambitoId = getAmbitoId(); | ||||
|         return logos.find(l => | ||||
|             l.ambitoGeograficoId === ambitoId && | ||||
|             l.agrupacionPoliticaId === selectedAgrupacion.id && | ||||
|             l.categoriaId === selectedCategoria.value | ||||
|         )?.logoUrl || ''; | ||||
|     }, [logos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]); | ||||
|  | ||||
|     useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]); | ||||
|  | ||||
|     const handleSave = async () => { | ||||
|         if (!selectedAgrupacion || !selectedCategoria) return; | ||||
|         const newLogoEntry: LogoAgrupacionCategoria = { | ||||
|             id: 0, | ||||
|             eleccionId: selectedEleccion.value, | ||||
|             agrupacionPoliticaId: selectedAgrupacion.id, | ||||
|             categoriaId: selectedCategoria.value, | ||||
|             ambitoGeograficoId: getAmbitoId(), | ||||
|             logoUrl: logoUrl || null | ||||
|         }; | ||||
|         try { | ||||
|             await updateLogos([newLogoEntry]); | ||||
|             queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] }); | ||||
|             alert('Override de logo guardado.'); | ||||
|         } catch { alert('Error al guardar.'); } | ||||
|     }; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Overrides de Logos</h3> | ||||
|             <p>Configure una imagen específica para un partido en un contexto determinado.</p> | ||||
|             <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}> | ||||
|                 <Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} /> | ||||
|                 <Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." isDisabled={!selectedEleccion} /> | ||||
|                 <Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedAgrupacion} onChange={setSelectedAgrupacion} placeholder="Seleccione Agrupación..." /> | ||||
|                 <Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} /> | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." /> | ||||
|                 ) : <div />} | ||||
|  | ||||
|                 {selectedAmbitoLevel.value === 'municipio' ? ( | ||||
|                     <Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} /> | ||||
|                 ) : <div />} | ||||
|             </div> | ||||
|             <div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}> | ||||
|                 <div style={{ flex: 1 }}> | ||||
|                     <label>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> | ||||
|     ); | ||||
| }; | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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}>×</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> | ||||
|     ); | ||||
| }; | ||||
| @@ -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.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,102 @@ | ||||
| // src/components/OrdenDiputadosNacionalesManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; | ||||
| import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable'; | ||||
| import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import { SortableItem } from './SortableItem'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const OrdenDiputadosNacionalesManager = () => { | ||||
|     // Estado para la lista que el usuario puede ordenar | ||||
|     const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]); | ||||
|      | ||||
|     // Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.) | ||||
|     const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|         queryKey: ['agrupaciones'], | ||||
|         queryFn: getAgrupaciones, | ||||
|     }); | ||||
|      | ||||
|     // Query 2: Obtener los datos de composición para saber qué partidos tienen bancas | ||||
|     const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({ | ||||
|         queryKey: ['composicionNacional', ELECCION_ID_NACIONAL], | ||||
|         queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL), | ||||
|     }); | ||||
|  | ||||
|     // Este efecto se ejecuta cuando los datos de las queries estén disponibles | ||||
|     useEffect(() => { | ||||
|         // No hacemos nada hasta que ambas queries hayan cargado sus datos | ||||
|         if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Creamos un Set con los IDs de los partidos que tienen al menos una banca de diputado | ||||
|         const partidosConBancasIds = new Set( | ||||
|             composicionData.diputados.partidos | ||||
|                 .filter(p => p.bancasTotales > 0) | ||||
|                 .map(p => p.id) | ||||
|         ); | ||||
|  | ||||
|         // Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes | ||||
|         const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id)); | ||||
|  | ||||
|         // Ordenamos la lista filtrada según el orden guardado en la BD | ||||
|         agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999)); | ||||
|          | ||||
|         // Actualizamos el estado que se renderiza y que el usuario puede ordenar | ||||
|         setAgrupacionesOrdenadas(agrupacionesFiltradas); | ||||
|  | ||||
|     }, [todasAgrupaciones, composicionData]); // Dependencias: se re-ejecuta si los datos cambian | ||||
|  | ||||
|     const sensors = useSensors( | ||||
|       useSensor(PointerSensor), | ||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|     ); | ||||
|  | ||||
|     const handleDragEnd = (event: DragEndEvent) => { | ||||
|       const { active, over } = event; | ||||
|       if (over && active.id !== over.id) { | ||||
|         setAgrupacionesOrdenadas((items) => { | ||||
|           const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|           const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|           return arrayMove(items, oldIndex, newIndex); | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const handleSaveOrder = async () => { | ||||
|         const idsOrdenados = agrupacionesOrdenadas.map(a => a.id); | ||||
|         try { | ||||
|             await updateOrden('diputados-nacionales', idsOrdenados); | ||||
|             alert('Orden de Diputados Nacionales guardado con éxito!'); | ||||
|         } catch (error) { | ||||
|             alert('Error al guardar el orden de Diputados Nacionales.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const isLoading = isLoadingAgrupaciones || isLoadingComposicion; | ||||
|     if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Ordenar Agrupaciones (Diputados Nacionales)</h3> | ||||
|             <p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p> | ||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|             <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> | ||||
|               <SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}> | ||||
|                 <ul className="sortable-list-horizontal"> | ||||
|                   {agrupacionesOrdenadas.map(agrupacion => ( | ||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -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.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -0,0 +1,94 @@ | ||||
| // src/components/OrdenSenadoresNacionalesManager.tsx | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { useQuery } from '@tanstack/react-query'; | ||||
| import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; | ||||
| import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable'; | ||||
| import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService'; | ||||
| import type { AgrupacionPolitica } from '../types'; | ||||
| import { SortableItem } from './SortableItem'; | ||||
| import './AgrupacionesManager.css'; | ||||
|  | ||||
| const ELECCION_ID_NACIONAL = 2; | ||||
|  | ||||
| export const OrdenSenadoresNacionalesManager = () => { | ||||
|     const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]); | ||||
|      | ||||
|     const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({ | ||||
|         queryKey: ['agrupaciones'], | ||||
|         queryFn: getAgrupaciones, | ||||
|     }); | ||||
|      | ||||
|     const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({ | ||||
|         queryKey: ['composicionNacional', ELECCION_ID_NACIONAL], | ||||
|         queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL), | ||||
|     }); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Creamos un Set con los IDs de los partidos que tienen al menos una banca de senador | ||||
|         const partidosConBancasIds = new Set( | ||||
|             composicionData.senadores.partidos | ||||
|                 .filter(p => p.bancasTotales > 0) | ||||
|                 .map(p => p.id) | ||||
|         ); | ||||
|  | ||||
|         const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id)); | ||||
|  | ||||
|         agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999)); | ||||
|          | ||||
|         setAgrupacionesOrdenadas(agrupacionesFiltradas); | ||||
|  | ||||
|     }, [todasAgrupaciones, composicionData]); | ||||
|  | ||||
|     const sensors = useSensors( | ||||
|       useSensor(PointerSensor), | ||||
|       useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) | ||||
|     ); | ||||
|  | ||||
|     const handleDragEnd = (event: DragEndEvent) => { | ||||
|       const { active, over } = event; | ||||
|       if (over && active.id !== over.id) { | ||||
|         setAgrupacionesOrdenadas((items) => { | ||||
|           const oldIndex = items.findIndex((item) => item.id === active.id); | ||||
|           const newIndex = items.findIndex((item) => item.id === over.id); | ||||
|           return arrayMove(items, oldIndex, newIndex); | ||||
|         }); | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const handleSaveOrder = async () => { | ||||
|         const idsOrdenados = agrupacionesOrdenadas.map(a => a.id); | ||||
|         try { | ||||
|             await updateOrden('senadores-nacionales', idsOrdenados); | ||||
|             alert('Orden de Senadores Nacionales guardado con éxito!'); | ||||
|         } catch (error) { | ||||
|             alert('Error al guardar el orden de Senadores Nacionales.'); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     const isLoading = isLoadingAgrupaciones || isLoadingComposicion; | ||||
|     if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>; | ||||
|  | ||||
|     return ( | ||||
|         <div className="admin-module"> | ||||
|             <h3>Ordenar Agrupaciones (Senado de la Nación)</h3> | ||||
|             <p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p> | ||||
|             <p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p> | ||||
|             <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> | ||||
|               <SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}> | ||||
|                 <ul className="sortable-list-horizontal"> | ||||
|                   {agrupacionesOrdenadas.map(agrupacion => ( | ||||
|                     <SortableItem key={agrupacion.id} id={agrupacion.id}> | ||||
|                       {agrupacion.nombreCorto || agrupacion.nombre} | ||||
|                     </SortableItem> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </SortableContext> | ||||
|             </DndContext> | ||||
|             <button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button> | ||||
|         </div> | ||||
|     ); | ||||
| }; | ||||
| @@ -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> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										140
									
								
								Elecciones-Web/frontend-admin/src/components/WorkerManager.tsx
									
									
									
									
									
										Normal 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> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										23
									
								
								Elecciones-Web/frontend-admin/src/constants/categorias.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,23 @@ | ||||
| // src/constants/categorias.ts | ||||
|  | ||||
| // Opciones para los selectores en el panel de administración | ||||
| export const CATEGORIAS_ADMIN_OPTIONS = [ | ||||
|     // Nacionales | ||||
|     { value: 1, label: 'Senadores Nacionales' }, | ||||
|     { value: 2, label: 'Diputados Nacionales' }, | ||||
|     // Provinciales | ||||
|     { value: 5, label: 'Senadores Provinciales' }, | ||||
|     { value: 6, label: 'Diputados Provinciales' }, | ||||
|     { value: 7, label: 'Concejales' }, | ||||
| ]; | ||||
|  | ||||
| export const CATEGORIAS_NACIONALES_OPTIONS = [ | ||||
|     { value: 1, label: 'Senadores Nacionales' }, | ||||
|     { value: 2, label: 'Diputados Nacionales' }, | ||||
| ]; | ||||
|  | ||||
| export const CATEGORIAS_PROVINCIALES_OPTIONS = [ | ||||
|     { value: 5, label: 'Senadores Provinciales' }, | ||||
|     { value: 6, label: 'Diputados Provinciales' }, | ||||
|     { value: 7, label: 'Concejales' }, | ||||
| ]; | ||||
							
								
								
									
										71
									
								
								Elecciones-Web/frontend-admin/src/context/AuthContext.tsx
									
									
									
									
									
										Normal 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; | ||||
| }; | ||||
							
								
								
									
										14
									
								
								Elecciones-Web/frontend-admin/src/context/authUtils.ts
									
									
									
									
									
										Normal 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')); | ||||
| } | ||||
							
								
								
									
										0
									
								
								Elecciones-Web/frontend-admin/src/index.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										22
									
								
								Elecciones-Web/frontend-admin/src/main.tsx
									
									
									
									
									
										Normal 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> | ||||
| ); | ||||
							
								
								
									
										194
									
								
								Elecciones-Web/frontend-admin/src/services/apiService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,194 @@ | ||||
| // 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; | ||||
| }; | ||||
							
								
								
									
										76
									
								
								Elecciones-Web/frontend-admin/src/types/index.ts
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
							
								
								
									
										1
									
								
								Elecciones-Web/frontend-admin/src/vite-env.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| /// <reference types="vite/client" /> | ||||
							
								
								
									
										27
									
								
								Elecciones-Web/frontend-admin/tsconfig.app.json
									
									
									
									
									
										Normal 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"] | ||||
| } | ||||
							
								
								
									
										7
									
								
								Elecciones-Web/frontend-admin/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| { | ||||
|   "files": [], | ||||
|   "references": [ | ||||
|     { "path": "./tsconfig.app.json" }, | ||||
|     { "path": "./tsconfig.node.json" } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										25
									
								
								Elecciones-Web/frontend-admin/tsconfig.node.json
									
									
									
									
									
										Normal 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"] | ||||
| } | ||||
							
								
								
									
										7
									
								
								Elecciones-Web/frontend-admin/vite.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,7 @@ | ||||
| import { defineConfig } from 'vite' | ||||
| import react from '@vitejs/plugin-react' | ||||
|  | ||||
| // https://vite.dev/config/ | ||||
| export default defineConfig({ | ||||
|   plugins: [react()], | ||||
| }) | ||||
| @@ -1,34 +1,20 @@ | ||||
| # --- Etapa 1: Build (Construcción) --- | ||||
| # Usamos una imagen de Node.js para instalar dependencias y construir la aplicación de React. | ||||
| #Dockerfile | ||||
| # --- Etapa 1: Build --- | ||||
| FROM node:20-alpine AS build | ||||
|  | ||||
| # Establecemos el directorio de trabajo dentro del contenedor. | ||||
| WORKDIR /app | ||||
|  | ||||
| # Copiamos los archivos de manifiesto del proyecto. | ||||
| COPY package.json ./ | ||||
| COPY package-lock.json ./ | ||||
|  | ||||
| # Instalamos las dependencias. | ||||
| COPY package*.json ./ | ||||
| RUN npm install | ||||
|  | ||||
| # Copiamos el resto del código fuente de la aplicación. | ||||
| COPY . . | ||||
|  | ||||
| # Ejecutamos el script de construcción de Vite para generar los archivos estáticos. | ||||
| RUN npm run build | ||||
|  | ||||
| # --- Etapa 2: Serve (Servir) --- | ||||
| # Usamos una imagen de Nginx, que es un servidor web muy ligero y eficiente. | ||||
| # Es ideal para servir archivos estáticos (HTML, CSS, JS). | ||||
| # --- Etapa 2: Producción --- | ||||
| FROM nginx:1.25-alpine | ||||
|  | ||||
| # Copiamos los archivos de producción construidos en la etapa anterior | ||||
| # al directorio por defecto donde Nginx sirve los archivos. | ||||
| COPY --from=build /app/dist /usr/share/nginx/html | ||||
|  | ||||
| # Exponemos el puerto 80 (el puerto por defecto de Nginx). | ||||
| EXPOSE 80 | ||||
| # Copia la configuración de Nginx al contenedor del frontend | ||||
| COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf | ||||
|  | ||||
| # El comando por defecto de la imagen de Nginx ya es iniciar el servidor, | ||||
| # así que no necesitamos un CMD o ENTRYPOINT. | ||||
| EXPOSE 80 | ||||
| # El CMD es opcional ya que la imagen base lo tiene, pero no hace daño | ||||
| CMD ["nginx", "-g", "daemon off;"] | ||||
| @@ -1,3 +1,4 @@ | ||||
| //eslint.config.js | ||||
| import js from '@eslint/js' | ||||
| import globals from 'globals' | ||||
| import reactHooks from 'eslint-plugin-react-hooks' | ||||
|   | ||||
							
								
								
									
										30
									
								
								Elecciones-Web/frontend/frontend.nginx.conf
									
									
									
									
									
										Normal 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; | ||||
|     } | ||||
| } | ||||
| @@ -2,12 +2,12 @@ | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/vite.svg" /> | ||||
|     <link rel="icon" type="image/svg+xml" href="/eldia.svg" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title>Vite + React + TS</title> | ||||
|     <title>Elecciones 2025 - Dev Showcase</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="root"></div> | ||||
|     <script type="module" src="/src/main.tsx"></script> | ||||
|   </body> | ||||
| </html> | ||||
| </html> | ||||
							
								
								
									
										2733
									
								
								Elecciones-Web/frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -10,17 +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": "^7.9.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/d3": "^7.4.3", | ||||
|     "@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", | ||||
|   | ||||
							
								
								
									
										88
									
								
								Elecciones-Web/frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,88 @@ | ||||
| // frontend/public/bootstrap.js | ||||
|  | ||||
| (function () { | ||||
|   // El dominio donde se alojan los widgets | ||||
|   const WIDGETS_HOST = 'https://elecciones2025.eldia.com'; | ||||
|  | ||||
|   // Función para cargar dinámicamente un script | ||||
|   function loadScript(src) { | ||||
|     return new Promise((resolve, reject) => { | ||||
|       const script = document.createElement('script'); | ||||
|       script.type = 'module'; | ||||
|       script.src = src; | ||||
|       script.onload = resolve; | ||||
|       script.onerror = reject; | ||||
|       document.head.appendChild(script); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Función para cargar dinámicamente una hoja de estilos | ||||
|   function loadCSS(href) { | ||||
|     const link = document.createElement('link'); | ||||
|     link.rel = 'stylesheet'; | ||||
|     link.href = href; | ||||
|     document.head.appendChild(link); | ||||
|   } | ||||
|  | ||||
|   // Función principal | ||||
|   async function initWidgets() { | ||||
|     try { | ||||
|       // 1. Obtener el manifest.json para saber los nombres de archivo actuales | ||||
|       const response = await fetch(`${WIDGETS_HOST}/manifest.json`); | ||||
|       if (!response.ok) { | ||||
|         throw new Error('No se pudo cargar el manifest de los widgets.'); | ||||
|       } | ||||
|       const manifest = await response.json(); | ||||
|  | ||||
|       // 2. Encontrar el punto de entrada principal (nuestro main.tsx) | ||||
|       const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); | ||||
|       if (!entryKey) { | ||||
|         throw new Error('No se encontró el punto de entrada en el manifest.'); | ||||
|       } | ||||
|  | ||||
|       const entry = manifest[entryKey]; | ||||
|       const jsUrl = `${WIDGETS_HOST}/${entry.file}`; | ||||
|  | ||||
|       // 3. Cargar el CSS si existe | ||||
|       if (entry.css && entry.css.length > 0) { | ||||
|         entry.css.forEach(cssFile => { | ||||
|           const cssUrl = `${WIDGETS_HOST}/${cssFile}`; | ||||
|           loadCSS(cssUrl); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       // 4. Cargar el JS principal y esperar a que esté listo | ||||
|       await loadScript(jsUrl); | ||||
|  | ||||
|  | ||||
|       // 5. Una vez cargado, llamar a la función de renderizado. | ||||
|       if (window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function') { | ||||
|         console.log('Bootstrap: La función render existe. Renderizando todos los widgets encontrados...'); | ||||
|          | ||||
|         const widgetContainers = document.querySelectorAll('[data-elecciones-widget]'); | ||||
|          | ||||
|         if (widgetContainers.length === 0) { | ||||
|             console.warn('Bootstrap: No se encontraron contenedores de widget en la página.'); | ||||
|         } | ||||
|  | ||||
|         widgetContainers.forEach(container => { | ||||
|           // 'dataset' es un objeto que contiene todos los atributos data-* | ||||
|           window.EleccionesWidgets.render(container, container.dataset); | ||||
|         }); | ||||
|       } else { | ||||
|         console.error('Bootstrap: ERROR CRÍTICO - La función render() NO SE ENCONTRÓ en window.EleccionesWidgets.'); | ||||
|         console.log('Bootstrap: Contenido de window.EleccionesWidgets:', window.EleccionesWidgets); | ||||
|       } | ||||
|  | ||||
|     } catch (error) { | ||||
|       console.error('Error al inicializar los widgets de elecciones:', error); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (document.readyState === 'loading') { // Aún cargando | ||||
|     document.addEventListener('DOMContentLoaded', initWidgets); | ||||
|   } else { // Ya cargado | ||||
|     initWidgets(); | ||||
|   } | ||||
|  | ||||
| })(); | ||||
							
								
								
									
										
											BIN
										
									
								
								Elecciones-Web/frontend/public/default-avatar.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 14 KiB | 
							
								
								
									
										6
									
								
								Elecciones-Web/frontend/public/eldia.svg
									
									
									
									
									
										Normal 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 | 
| After Width: | Height: | Size: 9.8 KiB | 
| After Width: | Height: | Size: 7.7 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/chaco.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.0 KiB | 
| After Width: | Height: | Size: 12 KiB | 
| @@ -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 | 
| @@ -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 | 
| After Width: | Height: | Size: 6.1 KiB | 
| After Width: | Height: | Size: 5.6 KiB | 
| After Width: | Height: | Size: 11 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/jujuy.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.5 KiB | 
| @@ -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 | 
| After Width: | Height: | Size: 7.2 KiB | 
| After Width: | Height: | Size: 8.4 KiB | 
| After Width: | Height: | Size: 7.4 KiB | 
| After Width: | Height: | Size: 9.1 KiB | 
| After Width: | Height: | Size: 7.6 KiB | 
							
								
								
									
										23
									
								
								Elecciones-Web/frontend/public/maps/provincias-svg/salta.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 11 KiB | 
| After Width: | Height: | Size: 8.7 KiB | 
| @@ -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 | 
| After Width: | Height: | Size: 12 KiB | 
| After Width: | Height: | Size: 5.8 KiB | 
| @@ -0,0 +1,23 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="19.023661mm" | ||||
|    height="28.363354mm" | ||||
|    viewBox="0 0 19.023661 28.363354" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      id="layer1" | ||||
|      transform="translate(-95.24991,-134.40833)"> | ||||
|     <path | ||||
|        d="m 99.059995,138.29771 0.02646,-0.21167 0.02646,-0.0265 0.185208,-0.39687 0.05292,-0.10584 v -0.0529 -0.21167 l 0.02646,-0.0529 0.02646,-0.10583 1.164167,-2.59292 0.10583,-0.13229 0.21167,0.0265 1.05833,0.34396 0.18521,0.0529 2.88396,0.0529 h 9.02229 l 0.15875,0.50271 0.0794,2.46063 v 0.68791 3.28084 6.27062 1.16417 l -0.3175,2.2225 -0.52917,3.4925 -0.47625,3.04271 -0.26458,1.66687 -0.47625,3.09563 -0.84667,-2.2225 -0.26458,-0.15875 -0.89959,-0.0529 -1.85208,0.0265 h -3.12208 l -0.1323,-0.0265 -0.0264,-0.0529 -0.0265,-0.0265 v -0.10583 -0.0529 -0.0264 l -0.0529,-0.0265 -0.0529,-0.0265 -0.1852,-0.0265 h -0.0529 l -0.0265,-0.0529 v -0.0264 l 0.0265,-0.18521 v -0.0265 -0.0265 -0.0265 l -0.0265,-0.0265 -0.0265,-0.0265 h -0.0529 l -0.21167,0.0529 -0.0529,-0.0265 -0.39687,-0.10583 -0.0794,-0.0265 -0.635,0.10584 h -0.10583 l -0.3175,-0.0265 h -0.0529 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0794,-0.0529 -0.0265,-0.0265 -0.0529,-0.0265 -0.34396,-0.0529 -0.0265,-0.0265 -0.0529,-0.0529 -0.0529,-0.0794 -0.0265,-0.0265 -0.39687,-0.23812 -0.0794,-0.0529 -0.0265,-0.0529 v -0.0794 l 0.0265,-0.15875 -0.0265,-0.0794 v -0.0265 l -0.0529,-0.0265 -1.08479,-0.26458 h -0.21167 l -0.608543,0.13229 -1.031875,0.29104 -1.5875,0.39687 -0.132292,-0.15875 -0.05292,-0.13229 V 158.67 l -0.05292,-0.13229 -0.370417,-0.635 -0.238125,-0.58208 -0.238125,-1.82563 0.02646,-0.52916 -0.105833,-1.21709 v -0.10583 l -0.02646,-0.0265 -0.02646,-0.0265 -0.105833,-0.10583 -0.211667,-0.13229 h -0.05292 l -0.02646,-0.0265 v -0.0265 l -0.02646,-0.0265 v -0.21166 -0.0529 l 0.02646,-0.0265 0.105834,-0.0529 0.02646,-0.0265 0.05292,-0.13229 0.07937,-0.1323 0.132291,-0.13229 0.07937,-0.10583 0.05292,-0.15875 0.05292,-0.34396 -0.05292,-0.92604 -0.47625,-2.27542 0.05292,-0.0529 0.07937,-0.0265 h 0.02646 0.05292 l 0.07937,0.0265 h 0.05292 0.07937 l 0.05292,-0.0265 0.05292,-0.0529 0.05292,-0.10583 0.15875,-0.50271 0.05292,-0.0529 0.02646,-0.0265 h 0.07937 0.02646 l -0.02646,-0.0794 -0.132291,-0.3175 -0.238125,-0.55563 v -0.13229 l 0.05292,-0.18521 h 0.05292 l 0.02646,-0.0264 h 0.105834 0.05292 0.02646 0.02646 l 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.05292,-0.10583 0.02646,-0.0529 v -0.0529 h -0.02646 l -0.105833,-0.0794 -0.105833,-0.0529 h -0.07937 l -0.105834,-0.0265 -0.07937,-0.0529 -0.02646,-0.0265 0.343958,-0.39687 0.07937,-0.0794 0.264584,-0.3175 0.07937,-0.0794 0.05292,-0.0265 0.132292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.132291,-0.37042 0.02646,-0.0265 0.105833,-0.13229 0.15875,-0.44979 0.05292,-0.26459 0.07937,-0.34395 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.02646,-0.0529 h 0.05292 l 0.02646,-0.0265 0.07937,-0.15875 0.185209,-0.76729 0.07937,-0.26458 0.02646,-0.10584 H 98.081 l 0.02646,-0.0265 h 0.07937 0.07937 0.02646 l 0.02646,-0.0529 0.02646,-0.0529 0.15875,-0.58208 0.02646,-0.0529 0.07937,-0.0265 0.343959,0.0529 0.02646,-0.0265 v -0.10583 -0.23813 l -0.02646,-0.44979 -0.105834,-0.635 v -0.0529 l 0.02646,-0.0529 0.185208,-0.66146 0.02646,-0.18521 z" | ||||
|        id="ARG" | ||||
|        name="Santiago del Estero" | ||||
|        style="stroke-width:0.264583" /> | ||||
|   </g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 3.4 KiB | 
| After Width: | Height: | Size: 5.7 KiB |