Test Public Side

This commit is contained in:
2025-09-03 13:49:35 -03:00
parent 32e85b9b9d
commit a81f1fe894
33 changed files with 1205 additions and 133 deletions

View File

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

View File

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

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Elecciones 2025 - El Día</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -2,12 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title> <title>Elecciones 2025 - Dev Showcase</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,71 @@
// public/bootstrap.js
(function() {
// --- CONFIGURACIÓN ---
// Cambie esto por el dominio final en producción
const WIDGETS_HOST = 'https://elecciones2025.eldia.com';
// --- FUNCIONES AUXILIARES (sin cambios) ---
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);
});
}
function loadCSS(href) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.appendChild(link);
}
// --- LÓGICA PRINCIPAL ---
async function initWidgets() {
try {
// 1. Obtener el manifest.json
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
if (!response.ok) throw new Error('No se pudo cargar el manifest.');
const manifest = await response.json();
// 2. Encontrar el punto de entrada principal (nuestro main.tsx)
// En modo 'lib', la entrada es el propio archivo de entrada.
const entryKey = 'src/main.tsx';
const entry = manifest[entryKey];
if (!entry) throw new Error('No se encontró el punto de entrada en el manifest.');
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
// 3. Cargar el CSS asociado, si existe
if (entry.css && entry.css.length > 0) {
entry.css.forEach(cssFile => {
const cssUrl = `${WIDGETS_HOST}/${cssFile}`;
loadCSS(cssUrl);
});
}
// 4. Cargar el script principal de la librería
await loadScript(jsUrl);
// 5. Esperar a que la página esté completamente cargada
// y luego llamar a la función de renderizado.
window.addEventListener('load', function () {
if (window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function') {
console.log('Elecciones Widgets listos. Renderizando...');
window.EleccionesWidgets.render();
}
});
} catch (error) {
console.error('Error al inicializar los widgets de Elecciones:', error);
}
}
// Iniciar el proceso
initWidgets();
})();

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

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

View File

@@ -1,6 +1,6 @@
// src/apiService.ts // src/apiService.ts
import axios from 'axios'; import axios from 'axios';
import type { ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker } from './types/types'; import type { ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, ApiResponseResultadosPorSeccion } from './types/types';
const API_BASE_URL = 'http://localhost:5217/api'; const API_BASE_URL = 'http://localhost:5217/api';
@@ -81,8 +81,13 @@ export const getBancasPorSeccion = async (seccionId: string): Promise<Proyeccion
/** /**
* Obtiene la lista de Secciones Electorales desde la API. * Obtiene la lista de Secciones Electorales desde la API.
*/ */
export const getSeccionesElectorales = async (): Promise<MunicipioSimple[]> => { export const getSeccionesElectorales = async (categoriaId?: number): Promise<MunicipioSimple[]> => {
const response = await apiClient.get('/catalogos/secciones-electorales'); let url = '/catalogos/secciones-electorales';
// Si se proporciona una categoría, la añadimos a la URL
if (categoriaId) {
url += `?categoriaId=${categoriaId}`;
}
const response = await apiClient.get(url);
return response.data; return response.data;
}; };
@@ -134,8 +139,8 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> =
return response.data; return response.data;
}; };
export const getResultadosConcejales = async (seccionId: string): Promise<ResultadoTicker[]> => { export const getResultadosPorSeccion = async (seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => {
const response = await apiClient.get(`/resultados/concejales/${seccionId}`); const response = await apiClient.get(`/resultados/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`);
return response.data; return response.data;
}; };

View File

@@ -0,0 +1,124 @@
// src/components/ConcejalesPorSeccionWidget.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import Select from 'react-select';
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica } from '../apiService';
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css'; // Reutilizamos los estilos del ticker
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
// Estilos personalizados para que el selector se vea bien
const customSelectStyles = {
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
menu: (base: any) => ({ ...base, zIndex: 10 }), // Para que el menú se superponga
};
const CATEGORIA_ID = 7; // ID para Concejales
export const ConcejalesPorSeccionWidget = () => {
const [secciones, setSecciones] = useState<MunicipioSimple[]>([]);
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
// useEffect para obtener la lista de secciones una sola vez
useEffect(() => {
getSeccionesElectorales().then(seccionesData => {
if (seccionesData && seccionesData.length > 0) {
const orden = new Map([
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
]);
const getOrden = (nombre: string) => {
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
return match ? orden.get(match[0]) ?? 99 : 99;
};
seccionesData.sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
setSecciones(seccionesData);
// Establecemos la primera sección de la lista ordenada como la por defecto
if (!selectedSeccion) {
setSelectedSeccion({ value: seccionesData[0].id, label: seccionesData[0].nombre });
}
}
});
}, [selectedSeccion]); // Dependencia para asegurar que no se resetee la selección del usuario
// Transformamos los datos para react-select
const seccionOptions = useMemo(() =>
secciones.map(s => ({ value: s.id, label: s.nombre })),
[secciones]);
// Query para obtener los resultados de la sección seleccionada
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
enabled: !!selectedSeccion,
});
const resultados = data?.resultados || [];
// Lógica para "Otros"
let displayResults: ResultadoTicker[] = resultados;
if (resultados && resultados.length > cantidadAMostrar) {
const topParties = resultados.slice(0, cantidadAMostrar - 1);
const otherParties = resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
const otrosEntry: ResultadoTicker = {
id: `otros-concejales-${selectedSeccion?.value}`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else if (resultados) {
displayResults = resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>CONCEJALES POR SECCIÓN</h3>
<Select
options={seccionOptions}
value={selectedSeccion}
onChange={(option) => setSelectedSeccion(option)}
isLoading={secciones.length === 0}
placeholder="Seleccionar sección..."
styles={customSelectStyles}
/>
</div>
<div className="ticker-results">
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
{!selectedSeccion && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
// src/components/ConcejalesTickerWidget.tsx
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css'; // Reutilizamos los mismos estilos
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const CATEGORIA_ID = 7; // ID para Concejales
export const ConcejalesTickerWidget = () => {
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
queryKey: ['resumenProvincial'],
queryFn: getResumenProvincial,
refetchInterval: 30000,
});
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
// Usamos useMemo para encontrar los datos específicos de Concejales
const ConcejalesData = useMemo(() => {
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
}, [categorias]);
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
if (error || !ConcejalesData) return <div className="ticker-card error"><p>Datos de Concejales no disponibles.</p></div>;
// Lógica para "Otros"
let displayResults: ResultadoTicker[] = ConcejalesData.resultados;
if (ConcejalesData.resultados.length > cantidadAMostrar) {
const topParties = ConcejalesData.resultados.slice(0, cantidadAMostrar - 1);
const otherParties = ConcejalesData.resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
const otrosEntry: ResultadoTicker = {
id: `otros-Concejales`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else {
displayResults = ConcejalesData.resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>RESUMEN DE {ConcejalesData.categoriaNombre}</h3>
<div className="ticker-stats">
<span>Mesas: <strong>{formatPercent(ConcejalesData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
<span>Part: <strong>{formatPercent(ConcejalesData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
</div>
</div>
<div className="ticker-results">
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,46 @@
// src/components/DevApp.tsx
import { BancasWidget } from './BancasWidget'
import { CongresoWidget } from './CongresoWidget'
import MapaBsAs from './MapaBsAs'
import { DipSenTickerWidget } from './DipSenTickerWidget'
import { TelegramaWidget } from './TelegramaWidget'
import { ConcejalesWidget } from './ConcejalesWidget'
import MapaBsAsSecciones from './MapaBsAsSecciones'
import { SenadoresWidget } from './SenadoresWidget'
import { DiputadosWidget } from './DiputadosWidget'
import { ResumenGeneralWidget } from './ResumenGeneralWidget'
import { SenadoresTickerWidget } from './SenadoresTickerWidget'
import { DiputadosTickerWidget } from './DiputadosTickerWidget'
import { ConcejalesTickerWidget } from './ConcejalesTickerWidget'
import { DiputadosPorSeccionWidget } from './DiputadosPorSeccionWidget'
import { SenadoresPorSeccionWidget } from './SenadoresPorSeccionWidget'
import { ConcejalesPorSeccionWidget } from './ConcejalesPorSeccionWidget'
import '../App.css';
export const DevApp = () => {
return (
<>
<h1 style={{ textAlign: 'center', fontFamily: 'sans-serif' }}>
Showcase de Widgets - Elecciones 2025
</h1>
<main>
<DipSenTickerWidget />
<ResumenGeneralWidget />
<SenadoresWidget />
<DiputadosWidget />
<ConcejalesWidget />
<SenadoresTickerWidget />
<DiputadosTickerWidget />
<ConcejalesTickerWidget />
<DiputadosPorSeccionWidget />
<SenadoresPorSeccionWidget />
<ConcejalesPorSeccionWidget />
<CongresoWidget />
<BancasWidget />
<MapaBsAs />
<MapaBsAsSecciones />
<TelegramaWidget />
</main>
</>
);
};

View File

@@ -1,4 +1,4 @@
// src/components/TickerWidget.tsx // src/components/DipSenTickerWidget.tsx
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getResumenProvincial, getConfiguracionPublica } from '../apiService'; import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types'; import type { CategoriaResumen, ResultadoTicker } from '../types/types';
@@ -8,7 +8,7 @@ import { useMemo } from 'react';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`; const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
export const TickerWidget = () => { export const DipSenTickerWidget = () => {
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({ const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
queryKey: ['resumenProvincial'], queryKey: ['resumenProvincial'],
queryFn: getResumenProvincial, queryFn: getResumenProvincial,

View File

@@ -0,0 +1,127 @@
// src/components/DiputadosPorSeccionWidget.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import Select from 'react-select';
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica } from '../apiService';
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const customSelectStyles = {
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
menu: (base: any) => ({ ...base, zIndex: 10 }),
};
const CATEGORIA_ID = 6; // ID para Diputados
export const DiputadosPorSeccionWidget = () => {
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10);
// Ahora usamos useQuery para obtener las secciones filtradas
const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({
queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché
queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría
});
// useEffect para establecer la primera sección por defecto
useEffect(() => {
if (secciones.length > 0 && !selectedSeccion) {
// Ordenamos aquí solo para la selección inicial
const orden = new Map([
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
]);
const getOrden = (nombre: string) => {
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
return match ? orden.get(match[0]) ?? 99 : 99;
};
const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre });
}
}, [secciones, selectedSeccion]);
const seccionOptions = useMemo(() =>
secciones
.map(s => ({ value: s.id, label: s.nombre }))
.sort((a, b) => { // Mantenemos el orden en el dropdown
const orden = new Map([
['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3],
['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7]
]);
return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99);
}),
[secciones]);
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
enabled: !!selectedSeccion,
});
const resultados = data?.resultados || [];
let displayResults: ResultadoTicker[] = resultados;
if (resultados && resultados.length > cantidadAMostrar) {
const topParties = resultados.slice(0, cantidadAMostrar - 1);
const otherParties = resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
const otrosEntry: ResultadoTicker = {
id: `otros-diputados-${selectedSeccion?.value}`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else if (resultados) {
displayResults = resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>DIPUTADOS POR SECCIÓN</h3>
<Select
options={seccionOptions}
value={selectedSeccion}
onChange={(option) => setSelectedSeccion(option)}
isLoading={isLoadingSecciones}
placeholder="Seleccionar sección..."
styles={customSelectStyles}
/>
</div>
<div className="ticker-results">
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
{!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
// src/components/DiputadosTickerWidget.tsx
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const CATEGORIA_ID = 6; // ID para Diputados
export const DiputadosTickerWidget = () => {
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
queryKey: ['resumenProvincial'],
queryFn: getResumenProvincial,
refetchInterval: 30000,
});
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
// Usamos useMemo para encontrar los datos específicos de Diputados
const diputadosData = useMemo(() => {
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
}, [categorias]);
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
if (error || !diputadosData) return <div className="ticker-card error"><p>Datos de Diputados no disponibles.</p></div>;
// Lógica para "Otros"
let displayResults: ResultadoTicker[] = diputadosData.resultados;
if (diputadosData.resultados.length > cantidadAMostrar) {
const topParties = diputadosData.resultados.slice(0, cantidadAMostrar - 1);
const otherParties = diputadosData.resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
const otrosEntry: ResultadoTicker = {
id: `otros-diputados`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else {
displayResults = diputadosData.resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>RESUMEN DE {diputadosData.categoriaNombre}</h3>
<div className="ticker-stats">
<span>Mesas: <strong>{formatPercent(diputadosData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
<span>Part: <strong>{formatPercent(diputadosData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
</div>
</div>
<div className="ticker-results">
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
// src/components/ResumenGeneralWidget.tsx
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
export const ResumenGeneralWidget = () => {
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
queryKey: ['resumenProvincial'],
queryFn: getResumenProvincial,
refetchInterval: 30000,
});
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
const aggregatedData = useMemo(() => {
if (!categorias) return null;
const legislativeCategories = categorias.filter(c => c.categoriaId === 5 || c.categoriaId === 6);
if (legislativeCategories.length === 0) return null;
const partyMap = new Map<string, Omit<ResultadoTicker, 'porcentaje'>>();
legislativeCategories.forEach(category => {
category.resultados.forEach(party => {
const existing = partyMap.get(party.id);
if (existing) {
existing.votos += party.votos;
} else {
// Clonamos el objeto para no modificar el original
partyMap.set(party.id, { ...party });
}
});
});
const resultsArray = Array.from(partyMap.values());
const grandTotalVotes = resultsArray.reduce((sum, party) => sum + party.votos, 0);
const finalResults: ResultadoTicker[] = resultsArray
.map(party => ({
...party,
porcentaje: grandTotalVotes > 0 ? (party.votos * 100 / grandTotalVotes) : 0,
}))
.sort((a, b) => b.votos - a.votos);
const avgMesas = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0), 0) / legislativeCategories.length;
const avgParticipacion = legislativeCategories.reduce((sum, cat) => sum + (cat.estadoRecuento?.participacionPorcentaje ?? 0), 0) / legislativeCategories.length;
return {
resultados: finalResults,
estadoRecuento: { mesasTotalizadasPorcentaje: avgMesas, participacionPorcentaje: avgParticipacion }
};
}, [categorias]);
if (isLoading) return <div className="ticker-card loading" style={{ gridColumn: '1 / -1' }}>Cargando resumen general...</div>;
if (error || !aggregatedData) return <div className="ticker-card error" style={{ gridColumn: '1 / -1' }}>No hay datos para el resumen general.</div>;
// Lógica para "Otros"
let displayResults: ResultadoTicker[] = aggregatedData.resultados;
if (aggregatedData.resultados.length > cantidadAMostrar) {
const topParties = aggregatedData.resultados.slice(0, cantidadAMostrar - 1);
const otherParties = aggregatedData.resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
const otrosEntry: ResultadoTicker = {
id: `otros-general`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else {
displayResults = aggregatedData.resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card" style={{ gridColumn: '1 / -1' }}>
<div className="ticker-header">
<h3>RESUMEN LEGISLATIVO PROVINCIAL</h3>
<div className="ticker-stats">
<span>Mesas (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.mesasTotalizadasPorcentaje)}</strong></span>
<span>Part (Prom.): <strong>{formatPercent(aggregatedData.estadoRecuento.participacionPorcentaje)}</strong></span>
</div>
</div>
<div className="ticker-results">
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,127 @@
// src/components/SenadoresPorSeccionWidget.tsx
import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import Select from 'react-select';
import { getSeccionesElectorales, getResultadosPorSeccion, getConfiguracionPublica } from '../apiService';
import type { MunicipioSimple, ResultadoTicker, ApiResponseResultadosPorSeccion } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css';
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const customSelectStyles = {
control: (base: any) => ({ ...base, minWidth: '220px', border: '1px solid #ced4da' }),
menu: (base: any) => ({ ...base, zIndex: 10 }),
};
const CATEGORIA_ID = 5; // ID para Senadores
export const SenadoresPorSeccionWidget = () => {
const [selectedSeccion, setSelectedSeccion] = useState<{ value: string; label: string } | null>(null);
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.ConcejalesResultadosCantidad || '5', 10);
// Ahora usamos useQuery para obtener las secciones filtradas
const { data: secciones = [], isLoading: isLoadingSecciones } = useQuery<MunicipioSimple[]>({
queryKey: ['seccionesElectorales', CATEGORIA_ID], // Key única para la caché
queryFn: () => getSeccionesElectorales(CATEGORIA_ID), // Pasamos el ID de la categoría
});
// useEffect para establecer la primera sección por defecto
useEffect(() => {
if (secciones.length > 0 && !selectedSeccion) {
// Ordenamos aquí solo para la selección inicial
const orden = new Map([
['Capital', 0], ['Primera', 1], ['Segunda', 2], ['Tercera', 3],
['Cuarta', 4], ['Quinta', 5], ['Sexta', 6], ['Séptima', 7]
]);
const getOrden = (nombre: string) => {
const match = nombre.match(/Capital|Primera|Segunda|Tercera|Cuarta|Quinta|Sexta|Séptima/);
return match ? orden.get(match[0]) ?? 99 : 99;
};
const seccionesOrdenadas = [...secciones].sort((a, b) => getOrden(a.nombre) - getOrden(b.nombre));
setSelectedSeccion({ value: seccionesOrdenadas[0].id, label: seccionesOrdenadas[0].nombre });
}
}, [secciones, selectedSeccion]);
const seccionOptions = useMemo(() =>
secciones
.map(s => ({ value: s.id, label: s.nombre }))
.sort((a, b) => { // Mantenemos el orden en el dropdown
const orden = new Map([
['Sección Capital', 0], ['Sección Primera', 1], ['Sección Segunda', 2], ['Sección Tercera', 3],
['Sección Cuarta', 4], ['Sección Quinta', 5], ['Sección Sexta', 6], ['Sección Séptima', 7]
]);
return (orden.get(a.label) ?? 99) - (orden.get(b.label) ?? 99);
}),
[secciones]);
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
enabled: !!selectedSeccion,
});
const resultados = data?.resultados || [];
let displayResults: ResultadoTicker[] = resultados;
if (resultados && resultados.length > cantidadAMostrar) {
const topParties = resultados.slice(0, cantidadAMostrar - 1);
const otherParties = resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + (party.porcentaje || 0), 0);
const otrosEntry: ResultadoTicker = {
id: `otros-senadores-${selectedSeccion?.value}`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else if (resultados) {
displayResults = resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>SENADORES POR SECCIÓN</h3>
<Select
options={seccionOptions}
value={selectedSeccion}
onChange={(option) => setSelectedSeccion(option)}
isLoading={isLoadingSecciones}
placeholder="Seleccionar sección..."
styles={customSelectStyles}
/>
</div>
<div className="ticker-results">
{(isLoadingResultados && selectedSeccion) && <p>Cargando...</p>}
{!selectedSeccion && !isLoadingSecciones && <p style={{textAlign: 'center', color: '#666'}}>Seleccione una sección.</p>}
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,84 @@
// src/components/SenadoresTickerWidget.tsx
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getResumenProvincial, getConfiguracionPublica } from '../apiService';
import type { CategoriaResumen, ResultadoTicker } from '../types/types';
import { ImageWithFallback } from './ImageWithFallback';
import './TickerWidget.css'; // Reutilizamos los mismos estilos
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
const CATEGORIA_ID = 5; // ID para Senadores
export const SenadoresTickerWidget = () => {
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
queryKey: ['resumenProvincial'],
queryFn: getResumenProvincial,
refetchInterval: 30000,
});
const { data: configData } = useQuery({
queryKey: ['configuracionPublica'],
queryFn: getConfiguracionPublica,
staleTime: 0,
});
const cantidadAMostrar = parseInt(configData?.TickerResultadosCantidad || '5', 10);
// Usamos useMemo para encontrar los datos específicos de Senadores
const senadoresData = useMemo(() => {
return categorias?.find(c => c.categoriaId === CATEGORIA_ID);
}, [categorias]);
if (isLoading) return <div className="ticker-card loading"><p>Cargando...</p></div>;
if (error || !senadoresData) return <div className="ticker-card error"><p>Datos de Senadores no disponibles.</p></div>;
// Lógica para "Otros"
let displayResults: ResultadoTicker[] = senadoresData.resultados;
if (senadoresData.resultados.length > cantidadAMostrar) {
const topParties = senadoresData.resultados.slice(0, cantidadAMostrar - 1);
const otherParties = senadoresData.resultados.slice(cantidadAMostrar - 1);
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
const otrosEntry: ResultadoTicker = {
id: `otros-senadores`,
nombre: 'Otros',
nombreCorto: 'Otros',
color: '#888888',
logoUrl: null,
votos: 0,
porcentaje: otrosPorcentaje,
};
displayResults = [...topParties, otrosEntry];
} else {
displayResults = senadoresData.resultados.slice(0, cantidadAMostrar);
}
return (
<div className="ticker-card">
<div className="ticker-header">
<h3>RESUMEN DE {senadoresData.categoriaNombre}</h3>
<div className="ticker-stats">
<span>Mesas: <strong>{formatPercent(senadoresData.estadoRecuento?.mesasTotalizadasPorcentaje || 0)}</strong></span>
<span>Part: <strong>{formatPercent(senadoresData.estadoRecuento?.participacionPorcentaje || 0)}</strong></span>
</div>
</div>
<div className="ticker-results">
{displayResults.map(partido => (
<div key={partido.id} className="ticker-party">
<div className="party-logo">
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc="/default-avatar.png" alt={`Logo de ${partido.nombre}`} />
</div>
<div className="party-details">
<div className="party-info">
<span className="party-name">{partido.nombreCorto || partido.nombre}</span>
<span className="party-percent">{formatPercent(partido.porcentaje)}</span>
</div>
<div className="party-bar-background">
<div className="party-bar-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}></div>
</div>
</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -35,10 +35,10 @@ export const SenadoresWidget = () => {
queryFn: () => getMunicipios(CATEGORIA_ID), // Pasamos el ID de la categoría queryFn: () => getMunicipios(CATEGORIA_ID), // Pasamos el ID de la categoría
}); });
// useEffect para establecer "LA PLATA" por defecto // useEffect para establecer "ALBERTI" por defecto
useEffect(() => { useEffect(() => {
if (municipios.length > 0 && !selectedMunicipio) { if (municipios.length > 0 && !selectedMunicipio) {
const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'LA PLATA'); const laPlata = municipios.find(m => m.nombre.toUpperCase() === 'ALBERTI');
if (laPlata) { if (laPlata) {
setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre }); setSelectedMunicipio({ value: laPlata.id, label: laPlata.nombre });
} }

View File

@@ -1,17 +1,83 @@
// src/main.tsx // src/main.tsx
import React from 'react' import React from 'react';
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App.tsx'
import './index.css'
// Crear un cliente de React Query import { BancasWidget } from './components/BancasWidget'
const queryClient = new QueryClient() import { CongresoWidget } from './components/CongresoWidget'
import MapaBsAs from './components/MapaBsAs'
import { DipSenTickerWidget } from './components/DipSenTickerWidget'
import { TelegramaWidget } from './components/TelegramaWidget'
import { ConcejalesWidget } from './components/ConcejalesWidget'
import MapaBsAsSecciones from './components/MapaBsAsSecciones'
import { SenadoresWidget } from './components/SenadoresWidget'
import { DiputadosWidget } from './components/DiputadosWidget'
import { ResumenGeneralWidget } from './components/ResumenGeneralWidget'
import { SenadoresTickerWidget } from './components/SenadoresTickerWidget'
import { DiputadosTickerWidget } from './components/DiputadosTickerWidget'
import { ConcejalesTickerWidget } from './components/ConcejalesTickerWidget'
import { DiputadosPorSeccionWidget } from './components/DiputadosPorSeccionWidget'
import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidget'
import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget'
ReactDOM.createRoot(document.getElementById('root')!).render( import './index.css';
<React.StrictMode> import { DevApp } from './components/DevApp';
<QueryClientProvider client={queryClient}>
<App /> const queryClient = new QueryClient();
</QueryClientProvider>
</React.StrictMode>, // Mapeamos el nombre del widget (del atributo data) al componente de React
) const WIDGET_MAP: Record<string, React.ElementType> = {
'resumen-senadores': SenadoresWidget,
'resumen-diputados': DiputadosWidget,
'resumen-concejales': ConcejalesWidget,
'congreso-provincial': CongresoWidget,
'distribucion-bancas': BancasWidget,
'mapa-municipios': MapaBsAs,
'mapa-secciones': MapaBsAsSecciones,
'consulta-telegramas': TelegramaWidget,
'ticker-senadores': SenadoresTickerWidget,
'ticker-diputados': DiputadosTickerWidget,
'ticker-concejales': ConcejalesTickerWidget,
'ticker-dip-sen': DipSenTickerWidget,
'resumen-general': ResumenGeneralWidget,
'diputados-por-seccion': DiputadosPorSeccionWidget,
'senadores-por-seccion': SenadoresPorSeccionWidget,
'concejales-por-seccion': ConcejalesPorSeccionWidget,
};
// Vite establece `import.meta.env.DEV` a `true` cuando ejecutamos 'npm run dev'
if (import.meta.env.DEV) {
// --- MODO DESARROLLO ---
// Renderizamos nuestra página de showcase en el div#root
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<DevApp />
</QueryClientProvider>
</React.StrictMode>
);
} else {
// --- MODO PRODUCCIÓN ---
// Exponemos la función de renderizado para el bootstrap.js
const renderWidgets = () => {
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
widgetContainers.forEach(container => {
const widgetName = (container as HTMLElement).dataset.eleccionesWidget;
if (widgetName && WIDGET_MAP[widgetName]) {
const WidgetComponent = WIDGET_MAP[widgetName];
const root = ReactDOM.createRoot(container);
root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<WidgetComponent />
</QueryClientProvider>
</React.StrictMode>
);
}
});
};
(window as any).EleccionesWidgets = {
render: renderWidgets
};
}

View File

@@ -105,4 +105,9 @@ export interface TelegramaData {
export interface CatalogoItem { export interface CatalogoItem {
id: string; id: string;
nombre: string; nombre: string;
}
export interface ApiResponseResultadosPorSeccion {
ultimaActualizacion: string;
resultados: ResultadoTicker[];
} }

View File

@@ -1,7 +1,39 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path';
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
// Mantenemos la configuración de librería para generar el UMD
lib: {
entry: path.resolve(__dirname, 'src/main.tsx'),
name: 'EleccionesWidgets',
fileName: 'elecciones-widgets', // Vite añadirá .umd.js automáticamente
formats: ['umd']
},
outDir: 'dist',
manifest: true, // Le dice a Vite que genere el manifest.json
rollupOptions: {
// Asegura que el manifest se genere incluso en modo librería
output: {
assetFileNames: "assets/[name].[hash].[ext]",
chunkFileNames: "assets/[name].[hash].js",
entryFileNames: "assets/[name].[hash].js"
}
}
},
server: {
proxy: {
// Cualquier petición que empiece con /api...
'/api': {
// ...redirígela a nuestro backend de .NET
target: 'http://localhost:5217',
// Cambia el origen de la petición para que el backend la acepte
changeOrigin: true,
// No necesitamos reescribir la ruta, ya que el backend espera /api/...
// rewrite: (path) => path.replace(/^\/api/, '')
},
}
}
}) })

View File

@@ -63,21 +63,40 @@ public class CatalogosController : ControllerBase
} }
[HttpGet("secciones-electorales")] [HttpGet("secciones-electorales")]
public async Task<IActionResult> GetSeccionesElectorales() public async Task<IActionResult> GetSeccionesElectorales([FromQuery] int? categoriaId)
{ {
var secciones = await _dbContext.AmbitosGeograficos var seccionesQuery = _dbContext.AmbitosGeograficos
.AsNoTracking() .AsNoTracking()
// Buscamos los ámbitos que son Secciones Electorales (Nivel 20) .Where(a => a.NivelId == 20); // Nivel 20 = Sección Electoral
.Where(a => a.NivelId == 20 && a.SeccionProvincialId != null)
.Select(a => new MunicipioSimpleDto // Reutilizamos el DTO porque tiene la misma estructura {Id, Nombre} // Si NO se proporciona una categoriaId, devolvemos todas las secciones.
{ if (categoriaId == null)
Id = a.SeccionProvincialId!, // El ID que usaremos es el SeccionProvincialId {
Nombre = a.Nombre var todasLasSecciones = await seccionesQuery
}) .OrderBy(a => a.Nombre)
.OrderBy(s => s.Nombre) .Select(a => new { Id = a.SeccionProvincialId, a.Nombre })
.ToListAsync();
return Ok(todasLasSecciones);
}
// --- LÓGICA DE FILTRADO ---
// 1. Encontrar las Secciones Provinciales que SÍ tienen resultados para la categoría solicitada.
// Usamos ProyeccionesBancas como fuente de verdad, ya que es el dato más relevante.
var seccionesActivasIds = await _dbContext.ProyeccionesBancas
.AsNoTracking()
.Where(p => p.CategoriaId == categoriaId)
.Select(p => p.AmbitoGeografico.SeccionProvincialId)
.Distinct()
.ToListAsync(); .ToListAsync();
return Ok(secciones); // 2. Filtramos la lista de secciones.
var seccionesFiltradas = await seccionesQuery
.Where(s => seccionesActivasIds.Contains(s.SeccionProvincialId))
.OrderBy(a => a.Nombre)
.Select(a => new { Id = a.SeccionProvincialId, a.Nombre })
.ToListAsync();
return Ok(seccionesFiltradas);
} }
// Nivel 20: Sección Electoral // Nivel 20: Sección Electoral

View File

@@ -531,8 +531,8 @@ public class ResultadosController : ControllerBase
return Ok(configuracionPublica); return Ok(configuracionPublica);
} }
[HttpGet("concejales/{seccionId}")] [HttpGet("seccion-resultados/{seccionId}")]
public async Task<IActionResult> GetResultadosConcejalesPorSeccion(string seccionId) public async Task<IActionResult> GetResultadosAgregadosPorSeccion(string seccionId, [FromQuery] int categoriaId)
{ {
var municipiosDeLaSeccion = await _dbContext.AmbitosGeograficos var municipiosDeLaSeccion = await _dbContext.AmbitosGeograficos
.AsNoTracking() .AsNoTracking()
@@ -548,40 +548,51 @@ public class ResultadosController : ControllerBase
var resultadosMunicipales = await _dbContext.ResultadosVotos var resultadosMunicipales = await _dbContext.ResultadosVotos
.AsNoTracking() .AsNoTracking()
.Include(r => r.AgrupacionPolitica) .Include(r => r.AgrupacionPolitica)
.Where(r => r.CategoriaId == 7 && municipiosDeLaSeccion.Contains(r.AmbitoGeograficoId)) // Usamos la categoriaId del parámetro
.Where(r => r.CategoriaId == categoriaId && municipiosDeLaSeccion.Contains(r.AmbitoGeograficoId))
.ToListAsync(); .ToListAsync();
var logosConcejales = await _dbContext.LogosAgrupacionesCategorias var logos = await _dbContext.LogosAgrupacionesCategorias
.AsNoTracking() .AsNoTracking()
.Where(l => l.CategoriaId == 7) // Usamos la categoriaId del parámetro
.Where(l => l.CategoriaId == categoriaId)
.ToDictionaryAsync(l => l.AgrupacionPoliticaId); .ToDictionaryAsync(l => l.AgrupacionPoliticaId);
var totalVotosSeccion = (decimal)resultadosMunicipales.Sum(r => r.CantidadVotos); var totalVotosSeccion = (decimal)resultadosMunicipales.Sum(r => r.CantidadVotos);
var resultadosFinales = resultadosMunicipales var resultadosFinales = resultadosMunicipales
// 1. Agrupamos por el ID del partido para evitar duplicados.
.GroupBy(r => r.AgrupacionPoliticaId) .GroupBy(r => r.AgrupacionPoliticaId)
.Select(g => new .Select(g => new
{ {
// 2. Obtenemos la entidad completa del primer elemento del grupo.
Agrupacion = g.First().AgrupacionPolitica, Agrupacion = g.First().AgrupacionPolitica,
Votos = g.Sum(r => r.CantidadVotos) Votos = g.Sum(r => r.CantidadVotos)
}) })
.OrderByDescending(r => r.Votos) .OrderByDescending(r => r.Votos)
.Select(r => new .Select(r => new
{ {
Id = r.Agrupacion.Id, // Aseguramos que el Id esté en el objeto final Id = r.Agrupacion.Id,
r.Agrupacion.Nombre, r.Agrupacion.Nombre,
r.Agrupacion.NombreCorto, r.Agrupacion.NombreCorto,
r.Agrupacion.Color, r.Agrupacion.Color,
LogoUrl = logosConcejales.GetValueOrDefault(r.Agrupacion.Id)?.LogoUrl, LogoUrl = logos.GetValueOrDefault(r.Agrupacion.Id)?.LogoUrl,
Votos = r.Votos, Votos = r.Votos,
// 3. Usamos el nombre de propiedad correcto que el frontend espera: 'votosPorcentaje' Porcentaje = totalVotosSeccion > 0 ? ((decimal)r.Votos * 100 / totalVotosSeccion) : 0
VotosPorcentaje = totalVotosSeccion > 0 ? ((decimal)r.Votos * 100 / totalVotosSeccion) : 0
}) })
.ToList(); .ToList();
return Ok(resultadosFinales); // Devolvemos un objeto para poder añadir la fecha de actualización
var seccionAmbito = await _dbContext.AmbitosGeograficos.AsNoTracking()
.FirstOrDefaultAsync(a => a.SeccionProvincialId == seccionId && a.NivelId == 20);
var estadoRecuento = seccionAmbito != null
? await _dbContext.EstadosRecuentos.AsNoTracking()
.FirstOrDefaultAsync(e => e.AmbitoGeograficoId == seccionAmbito.Id && e.CategoriaId == categoriaId)
: null;
return Ok(new
{
UltimaActualizacion = estadoRecuento?.FechaTotalizacion ?? DateTime.UtcNow,
Resultados = resultadosFinales
});
} }
[HttpGet("mapa-por-seccion")] [HttpGet("mapa-por-seccion")]

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6732a0e826a402495269212729673ebf1ff01916")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+32e85b9b9ddc7cd6422bfa52ff3c17fc6eff930b")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["h1yBBcAgq4jIQ1vINVvluRQMeuJlGA3/Zciq/j5c0AM=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","yVoZ4UnBcSOapsJIi046hnn7ylD3jAcEBUxQ\u002Brkvj/4=","/GfbpJthEWmsuz0uFx1QLHM7gyM1wLLeQgAIl4SzUD4=","i5\u002B5LcfxQD8meRAkQbVf4wMvjxSE4\u002BjCd2/FdPtMpms=","AvSkxVPIg0GjnB1RJ4hDNyo9p9GONrzDs8uVuixH\u002BOE=","IgT9pOgRnK37qfILj2QcjFoBZ180HMt\u002BScgje2iYOo4=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","FktDKOD55tGexuQTZYqJXFJKcfFUYha2UUveJ7i4d\u002B0=","6CAjHexjcmVc1caYyfNvMfhJRU6qtmi57Siv1ysirg0=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","yfarYtFn36AY3Hcf8kh\u002BwBS7wI/HSaScE\u002BTOvW5GoAM="],"CachedAssets":{},"CachedCopyCandidates":{}} {"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["h1yBBcAgq4jIQ1vINVvluRQMeuJlGA3/Zciq/j5c0AM=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","yVoZ4UnBcSOapsJIi046hnn7ylD3jAcEBUxQ\u002Brkvj/4=","/GfbpJthEWmsuz0uFx1QLHM7gyM1wLLeQgAIl4SzUD4=","i5\u002B5LcfxQD8meRAkQbVf4wMvjxSE4\u002BjCd2/FdPtMpms=","AvSkxVPIg0GjnB1RJ4hDNyo9p9GONrzDs8uVuixH\u002BOE=","IgT9pOgRnK37qfILj2QcjFoBZ180HMt\u002BScgje2iYOo4=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","TEsXImnzxFKTIq2f5fiDu7i6Ar/cbecW5MZ3z8Wb/a4=","6UbV83RDqihxiPJRSqBBce97kpcbB7parfVbqy/JDrA=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","4a8hgDNqfLAz7U5813iwPnUccUabxTlRH1USsCHCuGA="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["h1yBBcAgq4jIQ1vINVvluRQMeuJlGA3/Zciq/j5c0AM=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","yVoZ4UnBcSOapsJIi046hnn7ylD3jAcEBUxQ\u002Brkvj/4=","/GfbpJthEWmsuz0uFx1QLHM7gyM1wLLeQgAIl4SzUD4=","i5\u002B5LcfxQD8meRAkQbVf4wMvjxSE4\u002BjCd2/FdPtMpms=","AvSkxVPIg0GjnB1RJ4hDNyo9p9GONrzDs8uVuixH\u002BOE=","IgT9pOgRnK37qfILj2QcjFoBZ180HMt\u002BScgje2iYOo4=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","FktDKOD55tGexuQTZYqJXFJKcfFUYha2UUveJ7i4d\u002B0=","6CAjHexjcmVc1caYyfNvMfhJRU6qtmi57Siv1ysirg0=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","yfarYtFn36AY3Hcf8kh\u002BwBS7wI/HSaScE\u002BTOvW5GoAM="],"CachedAssets":{},"CachedCopyCandidates":{}} {"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["h1yBBcAgq4jIQ1vINVvluRQMeuJlGA3/Zciq/j5c0AM=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","6WTvWQ72AaZBYOVSmaxaci9tc1dW5p7IK9Kscjj2cb0=","vAy46VJ9Gp8QqG/Px4J1mj8jL6ws4/A01UKRmMYfYek=","cdgbHR/E4DJsddPc\u002BTpzoUMOVNaFJZm33Pw7AxU9Ees=","4r4JGR4hS5m4rsLfuCSZxzrknTBxKFkLQDXc\u002B2KbqTU=","yVoZ4UnBcSOapsJIi046hnn7ylD3jAcEBUxQ\u002Brkvj/4=","/GfbpJthEWmsuz0uFx1QLHM7gyM1wLLeQgAIl4SzUD4=","i5\u002B5LcfxQD8meRAkQbVf4wMvjxSE4\u002BjCd2/FdPtMpms=","AvSkxVPIg0GjnB1RJ4hDNyo9p9GONrzDs8uVuixH\u002BOE=","IgT9pOgRnK37qfILj2QcjFoBZ180HMt\u002BScgje2iYOo4=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","TEsXImnzxFKTIq2f5fiDu7i6Ar/cbecW5MZ3z8Wb/a4=","6UbV83RDqihxiPJRSqBBce97kpcbB7parfVbqy/JDrA=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","4a8hgDNqfLAz7U5813iwPnUccUabxTlRH1USsCHCuGA="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="GetEFProjectMetadata">
<MSBuild Condition=" '$(TargetFramework)' == '' "
Projects="$(MSBuildProjectFile)"
Targets="GetEFProjectMetadata"
Properties="TargetFramework=$(TargetFrameworks.Split(';')[0]);EFProjectMetadataFile=$(EFProjectMetadataFile)" />
<ItemGroup Condition=" '$(TargetFramework)' != '' ">
<EFProjectMetadata Include="AssemblyName: $(AssemblyName)" />
<EFProjectMetadata Include="Language: $(Language)" />
<EFProjectMetadata Include="OutputPath: $(OutputPath)" />
<EFProjectMetadata Include="Platform: $(Platform)" />
<EFProjectMetadata Include="PlatformTarget: $(PlatformTarget)" />
<EFProjectMetadata Include="ProjectAssetsFile: $(ProjectAssetsFile)" />
<EFProjectMetadata Include="ProjectDir: $(ProjectDir)" />
<EFProjectMetadata Include="RootNamespace: $(RootNamespace)" />
<EFProjectMetadata Include="RuntimeFrameworkVersion: $(RuntimeFrameworkVersion)" />
<EFProjectMetadata Include="TargetFileName: $(TargetFileName)" />
<EFProjectMetadata Include="TargetFrameworkMoniker: $(TargetFrameworkMoniker)" />
<EFProjectMetadata Include="Nullable: $(Nullable)" />
<EFProjectMetadata Include="TargetFramework: $(TargetFramework)" />
<EFProjectMetadata Include="TargetPlatformIdentifier: $(TargetPlatformIdentifier)" />
</ItemGroup>
<WriteLinesToFile Condition=" '$(TargetFramework)' != '' "
File="$(EFProjectMetadataFile)"
Lines="@(EFProjectMetadata)" />
</Target>
</Project>

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6732a0e826a402495269212729673ebf1ff01916")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+32e85b9b9ddc7cd6422bfa52ff3c17fc6eff930b")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Core")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Core")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6732a0e826a402495269212729673ebf1ff01916")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+32e85b9b9ddc7cd6422bfa52ff3c17fc6eff930b")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Database")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Database")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+6732a0e826a402495269212729673ebf1ff01916")] [assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+32e85b9b9ddc7cd6422bfa52ff3c17fc6eff930b")]
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")] [assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Infrastructure")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1,46 +1,54 @@
services: services:
# Servicio del Backend API
elecciones-api: elecciones-api:
build: build:
context: ./Elecciones-Web context: ./Elecciones-Web
dockerfile: src/Elecciones.Api/Dockerfile dockerfile: src/Elecciones.Api/Dockerfile
container_name: elecciones-api container_name: elecciones-api
restart: unless-stopped restart: unless-stopped
env_file: env_file: ./.env
- ./.env # Lee las variables de entorno desde el archivo .env # .NET expone el puerto 8080 por defecto en contenedores
expose:
- "8080"
networks: networks:
- elecciones-net - elecciones-net
- shared-net # Se conecta a la red compartida para hablar con la DB - shared-net
# No se exponen puertos directamente al host. El proxy se encarga.
# Servicio del Worker para la ingesta de datos # Servicio del Worker (sin cambios)
elecciones-worker: elecciones-worker:
build: build:
context: ./Elecciones-Web context: ./Elecciones-Web
dockerfile: src/Elecciones.Worker/Dockerfile dockerfile: src/Elecciones.Worker/Dockerfile
container_name: elecciones-worker container_name: elecciones-worker
restart: unless-stopped restart: unless-stopped
env_file: env_file: ./.env
- ./.env
networks: networks:
- shared-net # Solo necesita acceso a la DB y a la API electoral (internet). - shared-net
volumes: volumes:
# Mapea el directorio /app/logs de dentro del contenedor
# a un directorio llamado 'logs-worker' en la raíz de tu proyecto en la máquina local.
- ./logs-worker:/app/logs - ./logs-worker:/app/logs
# No se exponen puertos.
# Servicio del Frontend (servido por Nginx) # Servicio del Frontend Público (sin cambios)
# Lo definimos ahora, aunque lo construiremos más adelante.
elecciones-frontend: elecciones-frontend:
build: build:
context: ./Elecciones-Web/frontend context: ./Elecciones-Web/frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: elecciones-frontend container_name: elecciones-frontend
restart: unless-stopped restart: unless-stopped
expose:
- "80"
networks:
- elecciones-net
# --- Frontend Admin ---
elecciones-frontend-admin:
build:
context: ./Elecciones-Web/frontend-admin
dockerfile: Dockerfile
container_name: elecciones-frontend-admin
restart: unless-stopped
expose:
- "80"
networks: networks:
- elecciones-net - elecciones-net
# No se exponen puertos.
# Proxy Inverso que expone los servicios al exterior # Proxy Inverso que expone los servicios al exterior
proxy: proxy:
@@ -48,21 +56,22 @@ services:
container_name: elecciones-proxy container_name: elecciones-proxy
restart: unless-stopped restart: unless-stopped
volumes: volumes:
# Mapea nuestro archivo de configuración local al contenedor de Nginx # Mapea nuestro archivo de configuración de proxy local al contenedor
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf
ports: ports:
# ÚNICO PUNTO DE ENTRADA: Expone el puerto 80 del contenedor al puerto 8600 del host. # Puerto para el sitio público/widgets
- "8600:80" - "8600:80"
# --- NUEVO PUERTO: Puerto para el panel de administración ---
- "8700:81"
networks: networks:
- elecciones-net - elecciones-net
depends_on: depends_on:
- elecciones-api - elecciones-api
- elecciones-frontend - elecciones-frontend
- elecciones-frontend-admin
networks: networks:
elecciones-net: elecciones-net:
driver: bridge driver: bridge
# La red 'shared-net' ya existe en Docker.
shared-net: shared-net:
external: true external: true

View File

@@ -1,55 +1,67 @@
# ./proxy/nginx.conf # ./proxy/nginx.conf
# Definimos los upstreams para referirnos a nuestros servicios por su nombre. # --- Upstreams (Definiciones de nuestros servicios) ---
# Docker Compose se encargará de resolver estos nombres a las IPs de los contenedores.
upstream backend_api { upstream backend_api {
# Apunta al servicio de la API en el puerto que expone internamente.
# Por defecto, .NET en contenedores usa el 8080.
server elecciones-api:8080; server elecciones-api:8080;
} }
upstream frontend_public {
upstream frontend_app {
# Apunta al contenedor que sirve los archivos estáticos del frontend.
# El Dockerfile del frontend usará un Nginx que escucha en el puerto 80.
server elecciones-frontend:80; server elecciones-frontend:80;
} }
upstream frontend_admin {
server elecciones-frontend-admin:80;
}
# --- Servidor para el Frontend Público / Widgets (Puerto 80) ---
server { server {
listen 80; listen 80;
server_name _; # Escucha en cualquier hostname que llegue a este proxy. server_name elecciones2025.eldia.com; # Servirá para este dominio
# --- UBICACIÓN PARA LA API --- # --- RUTA PARA LA API ---
# Gestiona todas las peticiones que comienzan con /api/
location /api/ { location /api/ {
proxy_pass http://backend_api; proxy_pass http://backend_api;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
# --- UBICACIÓN PARA EL FRONTEND (RAÍZ Y CUALQUIER OTRA RUTA) --- # --- RUTA PARA EL FRONTEND PÚBLICO ---
# Gestiona todas las demás peticiones.
location / { location / {
# ¡CRUCIAL! Añade la cabecera CORS para permitir que los widgets # ¡CRUCIAL! Permite que los widgets se incrusten en otros sitios.
# se incrusten y carguen desde otros dominios como www.eldia.com. # Esto es más seguro que un '*' genérico.
add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Origin' 'https://www.eldia.com, https://extras.eldia.com, http://localhost:5173' always;
# Manejo de peticiones pre-vuelo (preflight) OPTIONS.
if ($request_method = 'OPTIONS') { if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204; return 204;
} }
# Redirige la petición al contenedor del frontend. proxy_pass http://frontend_public;
proxy_pass http://frontend_app; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
# --- SERVIDOR para el Frontend de Administración (Puerto 81) ---
server {
listen 81;
server_name _; # Escucha en cualquier hostname que llegue a este puerto
# --- RUTA PARA LA API ---
# Es necesario repetir esto para que el panel de admin pueda hablar con la API.
location /api/ {
proxy_pass http://backend_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# --- RUTA PARA LA APP DE ADMIN ---
location / {
proxy_pass http://frontend_admin;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }