diff --git a/Elecciones-Web/frontend-admin/src/components/AgrupacionesManager.tsx b/Elecciones-Web/frontend-admin/src/components/AgrupacionesManager.tsx index 6b0aff9..d514707 100644 --- a/Elecciones-Web/frontend-admin/src/components/AgrupacionesManager.tsx +++ b/Elecciones-Web/frontend-admin/src/components/AgrupacionesManager.tsx @@ -1,148 +1,110 @@ -// src/components/AgrupacionesManager.tsx +// EN: src/components/AgrupacionesManager.tsx import { useState, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import Select from 'react-select'; // Importamos Select +import Select from 'react-select'; import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService'; -import type { AgrupacionPolitica, LogoAgrupacionCategoria } from '../types'; +import type { AgrupacionPolitica, LogoAgrupacionCategoria, UpdateAgrupacionData } from '../types'; import './AgrupacionesManager.css'; -// Constantes para los IDs de categoría -const SENADORES_ID = 5; -const DIPUTADOS_ID = 6; -const CONCEJALES_ID = 7; -const SENADORES_NAC_ID = 1; -const DIPUTADOS_NAC_ID = 2; +const GLOBAL_ELECTION_ID = 0; -// Opciones para el nuevo selector de Elección const ELECCION_OPTIONS = [ - { value: 2, label: 'Elecciones Nacionales' }, - { value: 1, label: 'Elecciones Provinciales' } + { 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'; - const sanitized = color.replace(/[^#0-9a-fA-F]/g, ''); - return sanitized.startsWith('#') ? sanitized : `#${sanitized}`; + return color.startsWith('#') ? color : `#${color}`; }; export const AgrupacionesManager = () => { const queryClient = useQueryClient(); - - // --- NUEVO ESTADO PARA LA ELECCIÓN SELECCIONADA --- const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]); - - const [editedAgrupaciones, setEditedAgrupaciones] = useState>>({}); - const [editedLogos, setEditedLogos] = useState([]); + const [editedAgrupaciones, setEditedAgrupaciones] = useState>({}); + const [editedLogos, setEditedLogos] = useState>({}); const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery({ - queryKey: ['agrupaciones'], - queryFn: getAgrupaciones, + queryKey: ['agrupaciones'], queryFn: getAgrupaciones, }); - - // --- CORRECCIÓN: La query de logos ahora depende del ID de la elección --- + const { data: logos = [], isLoading: isLoadingLogos } = useQuery({ - queryKey: ['logos', selectedEleccion.value], - queryFn: () => getLogos(selectedEleccion.value), // Pasamos el valor numérico + queryKey: ['allLogos'], + queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()), }); useEffect(() => { - if (agrupaciones && agrupaciones.length > 0) { - setEditedAgrupaciones(prev => { - if (Object.keys(prev).length === 0) { - return Object.fromEntries(agrupaciones.map(a => [a.id, {}])); - } - return prev; - }); + if (agrupaciones.length > 0) { + const initialEdits = Object.fromEntries( + agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }]) + ); + setEditedAgrupaciones(initialEdits); } }, [agrupaciones]); - - // Este useEffect ahora también depende de 'logos' para reinicializarse + useEffect(() => { if (logos) { - setEditedLogos(JSON.parse(JSON.stringify(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) => { - setEditedAgrupaciones(prev => ({ - ...prev, - [id]: { ...prev[id], [field]: value } - })); + const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => { + setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } })); }; - const handleLogoChange = (agrupacionId: string, categoriaId: number, value: string) => { - setEditedLogos(prev => { - const newLogos = [...prev]; - const existing = newLogos.find(l => - l.eleccionId === selectedEleccion.value && - l.agrupacionPoliticaId === agrupacionId && - l.categoriaId === categoriaId && - l.ambitoGeograficoId == null - ); - - if (existing) { - existing.logoUrl = value; - } else { - newLogos.push({ - id: 0, - eleccionId: selectedEleccion.value, // Añadimos el ID de la elección - agrupacionPoliticaId: agrupacionId, - categoriaId, - logoUrl: value, - ambitoGeograficoId: null - }); - } - return newLogos; - }); + const handleLogoInputChange = (agrupacionId: string, value: string | null) => { + const key = `${agrupacionId}-${selectedEleccion.value}`; + setEditedLogos(prev => ({ ...prev, [key]: value })); }; - + const handleSaveAll = async () => { try { - const agrupacionPromises = Object.entries(editedAgrupaciones).map(([id, changes]) => { - if (Object.keys(changes).length > 0) { - const original = agrupaciones.find(a => a.id === id); - if (original) { - return updateAgrupacion(id, { ...original, ...changes }); - } - } - return Promise.resolve(); + const agrupacionPromises = agrupaciones.map(agrupacion => { + const changes = editedAgrupaciones[agrupacion.id] || {}; + const payload: UpdateAgrupacionData = { + nombreCorto: changes.nombreCorto ?? agrupacion.nombreCorto, + color: changes.color ?? agrupacion.color, + }; + return updateAgrupacion(agrupacion.id, payload); }); + + // --- CORRECCIÓN CLAVE 2: Enviar `null` a la API en lugar de `0` --- + const logosPayload = Object.entries(editedLogos) + .map(([key, logoUrl]) => { + const [agrupacionPoliticaId, eleccionIdStr] = key.split('-'); + return { id: 0, eleccionId: parseInt(eleccionIdStr), agrupacionPoliticaId, categoriaId: 0, logoUrl: logoUrl || null, ambitoGeograficoId: null }; + }); - const logoPromise = updateLogos(editedLogos); + const logoPromise = updateLogos(logosPayload); await Promise.all([...agrupacionPromises, logoPromise]); - - queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); - queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] }); // Invalidamos la query correcta - + + await queryClient.invalidateQueries({ queryKey: ['agrupaciones'] }); + await queryClient.invalidateQueries({ queryKey: ['allLogos'] }); alert('¡Todos los cambios han sido guardados!'); - } catch (err) { - console.error("Error al guardar todo:", err); - alert("Ocurrió un error al guardar los cambios."); - } + } catch (err) { console.error("Error al guardar todo:", err); alert("Ocurrió un error."); } + }; + + const getLogoValue = (agrupacionId: string): string => { + const key = `${agrupacionId}-${selectedEleccion.value}`; + return editedLogos[key] ?? ''; }; const isLoading = isLoadingAgrupaciones || isLoadingLogos; - const getLogoUrl = (agrupacionId: string, categoriaId: number) => { - return editedLogos.find(l => - l.eleccionId === selectedEleccion.value && - l.agrupacionPoliticaId === agrupacionId && - l.categoriaId === categoriaId && - l.ambitoGeograficoId == null - )?.logoUrl || ''; - }; - return (
-

Gestión de Agrupaciones y Logos Generales

-
- setSelectedEleccion(opt!)} />
@@ -155,42 +117,23 @@ export const AgrupacionesManager = () => { Nombre Nombre Corto Color - {/* --- CABECERAS CONDICIONALES --- */} - {selectedEleccion.value === 2 ? ( - <> - Logo Senadores Nac. - Logo Diputados Nac. - - ) : ( - <> - Logo Senadores Prov. - Logo Diputados Prov. - Logo Concejales - - )} + Logo {agrupaciones.map(agrupacion => ( {agrupacion.nombre} - handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /> + handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /> + handleInputChange(agrupacion.id, 'color', e.target.value)} /> - handleInputChange(agrupacion.id, 'color', e.target.value)} /> + handleLogoInputChange(agrupacion.id, e.target.value)} + /> - {/* --- CELDAS CONDICIONALES --- */} - {selectedEleccion.value === 2 ? ( - <> - handleLogoChange(agrupacion.id, SENADORES_NAC_ID, e.target.value)} /> - handleLogoChange(agrupacion.id, DIPUTADOS_NAC_ID, e.target.value)} /> - - ) : ( - <> - handleLogoChange(agrupacion.id, SENADORES_ID, e.target.value)} /> - handleLogoChange(agrupacion.id, DIPUTADOS_ID, e.target.value)} /> - handleLogoChange(agrupacion.id, CONCEJALES_ID, e.target.value)} /> - - )} ))} diff --git a/Elecciones-Web/frontend-admin/src/components/LogoOverridesManager.tsx b/Elecciones-Web/frontend-admin/src/components/LogoOverridesManager.tsx index 860df5d..5cf4316 100644 --- a/Elecciones-Web/frontend-admin/src/components/LogoOverridesManager.tsx +++ b/Elecciones-Web/frontend-admin/src/components/LogoOverridesManager.tsx @@ -7,6 +7,7 @@ import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria, Prov 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' } ]; @@ -44,7 +45,7 @@ export const LogoOverridesManager = () => { const getAmbitoId = () => { if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id); if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id); - return null; + return 0; }; const currentLogo = useMemo(() => { diff --git a/Elecciones-Web/frontend-admin/src/types/index.ts b/Elecciones-Web/frontend-admin/src/types/index.ts index 4d8f00a..cae8239 100644 --- a/Elecciones-Web/frontend-admin/src/types/index.ts +++ b/Elecciones-Web/frontend-admin/src/types/index.ts @@ -8,7 +8,6 @@ export interface AgrupacionPolitica { color: string | null; ordenDiputados: number | null; ordenSenadores: number | null; - // Añadimos los nuevos campos para el ordenamiento nacional ordenDiputadosNacionales: number | null; ordenSenadoresNacionales: number | null; } @@ -58,7 +57,7 @@ export interface LogoAgrupacionCategoria { id: number; eleccionId: number; // Clave para diferenciar agrupacionPoliticaId: string; - categoriaId: number; + categoriaId: number | null; logoUrl: string | null; ambitoGeograficoId: number | null; } diff --git a/Elecciones-Web/frontend/package-lock.json b/Elecciones-Web/frontend/package-lock.json index 9429789..05cc7c3 100644 --- a/Elecciones-Web/frontend/package-lock.json +++ b/Elecciones-Web/frontend/package-lock.json @@ -21,11 +21,13 @@ "react": "^19.1.1", "react-circular-progressbar": "^2.2.0", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-pdf": "^10.1.0", "react-select": "^5.10.2", "react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support", "react-tooltip": "^5.29.1", + "swiper": "^12.0.2", "topojson-client": "^3.1.0", "vite-plugin-svgr": "^4.5.0" }, @@ -3962,6 +3964,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4745,6 +4756,23 @@ "react": "^19.1.1" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -5120,6 +5148,25 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "license": "MIT" }, + "node_modules/swiper": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.2.tgz", + "integrity": "sha512-y8F6fDGXmTVVgwqJj6I00l4FdGuhpFJn0U/9Ucn1MwWOw3NdLV8aH88pZOjyhBgU/6PyBlUx+JuAQ5KMWz906Q==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/terser": { "version": "5.43.1", "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", diff --git a/Elecciones-Web/frontend/package.json b/Elecciones-Web/frontend/package.json index dd0604a..b2bf53a 100644 --- a/Elecciones-Web/frontend/package.json +++ b/Elecciones-Web/frontend/package.json @@ -23,11 +23,13 @@ "react": "^19.1.1", "react-circular-progressbar": "^2.2.0", "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", "react-pdf": "^10.1.0", "react-select": "^5.10.2", "react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support", "react-tooltip": "^5.29.1", + "swiper": "^12.0.2", "topojson-client": "^3.1.0", "vite-plugin-svgr": "^4.5.0" }, diff --git a/Elecciones-Web/frontend/src/apiService.ts b/Elecciones-Web/frontend/src/apiService.ts index 28e6e7c..8875ec3 100644 --- a/Elecciones-Web/frontend/src/apiService.ts +++ b/Elecciones-Web/frontend/src/apiService.ts @@ -1,9 +1,12 @@ // src/apiService.ts import axios from 'axios'; -import type { ApiResponseRankingMunicipio, ApiResponseRankingSeccion, - ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, - TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, - ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia } from './types/types'; +import type { + ApiResponseRankingMunicipio, ApiResponseRankingSeccion, + ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, + TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, + ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia, + CategoriaResumenHome +} from './types/types'; /** * URL base para las llamadas a la API. @@ -76,7 +79,6 @@ export interface BancadaDetalle { export interface ConfiguracionPublica { TickerResultadosCantidad?: string; ConcejalesResultadosCantidad?: string; - // ... otras claves públicas que pueda añadir en el futuro } export interface ResultadoDetalleSeccion { @@ -88,29 +90,35 @@ export interface ResultadoDetalleSeccion { } 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; + id: string; + nombre: string; + nombreCorto: string | null; + color: string | null; + bancasFijos: number; + bancasGanadas: number; + bancasTotales: number; + ordenDiputadosNacionales: number | null; + ordenSenadoresNacionales: number | null; } export interface CamaraComposicionNacional { - camaraNombre: string; - totalBancas: number; - bancasEnJuego: number; - partidos: PartidoComposicionNacional[]; - presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null; - ultimaActualizacion: string; + camaraNombre: string; + totalBancas: number; + bancasEnJuego: number; + partidos: PartidoComposicionNacional[]; + presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null; + ultimaActualizacion: string; } export interface ComposicionNacionalData { - diputados: CamaraComposicionNacional; - senadores: CamaraComposicionNacional; + diputados: CamaraComposicionNacional; + senadores: CamaraComposicionNacional; +} + +export interface ResumenParams { + focoDistritoId?: string; + focoCategoriaId?: number; + cantidadResultados?: number; } export const getResumenProvincial = async (eleccionId: number): Promise => { @@ -239,27 +247,55 @@ export const getEstablecimientosPorMunicipio = async (municipioId: string): Prom }; export const getPanelElectoral = async (eleccionId: number, ambitoId: string | null, categoriaId: number): Promise => { - - // Construimos la URL base - let url = ambitoId - ? `/elecciones/${eleccionId}/panel/${ambitoId}` - : `/elecciones/${eleccionId}/panel`; - // Añadimos categoriaId como un query parameter - url += `?categoriaId=${categoriaId}`; - - const { data } = await apiClient.get(url); - return data; + // Construimos la URL base + let url = ambitoId + ? `/elecciones/${eleccionId}/panel/${ambitoId}` + : `/elecciones/${eleccionId}/panel`; + + // Añadimos categoriaId como un query parameter + url += `?categoriaId=${categoriaId}`; + + const { data } = await apiClient.get(url); + return data; }; export const getComposicionNacional = async (eleccionId: number): Promise => { - const { data } = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`); - return data; + const { data } = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`); + return data; }; // 11. Endpoint para el widget de tarjetas nacionales -export const getResumenPorProvincia = async (eleccionId: number): Promise => { - // Usamos el cliente público ya que son datos de resultados - const { data } = await apiClient.get(`/elecciones/${eleccionId}/resumen-por-provincia`); +export const getResumenPorProvincia = async (eleccionId: number, params: ResumenParams = {}): Promise => { + // Usamos URLSearchParams para construir la query string de forma segura y limpia + const queryParams = new URLSearchParams(); + + if (params.focoDistritoId) { + queryParams.append('focoDistritoId', params.focoDistritoId); + } + if (params.focoCategoriaId) { + queryParams.append('focoCategoriaId', params.focoCategoriaId.toString()); + } + if (params.cantidadResultados) { + queryParams.append('cantidadResultados', params.cantidadResultados.toString()); + } + + const queryString = queryParams.toString(); + + // Añadimos la query string a la URL solo si tiene contenido + const url = `/elecciones/${eleccionId}/resumen-por-provincia${queryString ? `?${queryString}` : ''}`; + + const { data } = await apiClient.get(url); + return data; +}; + +export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise => { + const queryParams = new URLSearchParams({ + eleccionId: eleccionId.toString(), + distritoId: distritoId, + categoriaId: categoriaId.toString(), + }); + const url = `/elecciones/home-resumen?${queryParams.toString()}`; + const { data } = await apiClient.get(url); return data; }; \ No newline at end of file diff --git a/Elecciones-Web/frontend/src/components/common/DevApp.tsx b/Elecciones-Web/frontend/src/components/common/DevApp.tsx index 750ba7b..62eaccd 100644 --- a/Elecciones-Web/frontend/src/components/common/DevApp.tsx +++ b/Elecciones-Web/frontend/src/components/common/DevApp.tsx @@ -38,7 +38,7 @@ export const DevApp = () => { - + diff --git a/Elecciones-Web/frontend/src/features/legislativas/DevAppLegislativas.tsx b/Elecciones-Web/frontend/src/features/legislativas/DevAppLegislativas.tsx index 207f7cc..4709b1a 100644 --- a/Elecciones-Web/frontend/src/features/legislativas/DevAppLegislativas.tsx +++ b/Elecciones-Web/frontend/src/features/legislativas/DevAppLegislativas.tsx @@ -1,14 +1,145 @@ -// src/features/legislativas/rovinciales/DevAppLegislativas.tsx +// src/features/legislativas/nacionales/DevAppLegislativas.tsx +import { useState } from 'react'; // <-- Importar useState import { ResultadosNacionalesCardsWidget } from './nacionales/ResultadosNacionalesCardsWidget'; import { CongresoNacionalWidget } from './nacionales/CongresoNacionalWidget'; import { PanelNacionalWidget } from './nacionales/PanelNacionalWidget'; +import { HomeCarouselWidget } from './nacionales/HomeCarouselWidget'; import './DevAppStyle.css' +// --- NUEVO COMPONENTE REUTILIZABLE PARA CONTENIDO COLAPSABLE --- +const CollapsibleWidgetWrapper = ({ children }: { children: React.ReactNode }) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+
+ {children} +
+ +
+ ); +}; + export const DevAppLegislativas = () => { + // Estilos para los separadores y descripciones para mejorar la legibilidad + const sectionStyle = { + border: '2px solid #007bff', + borderRadius: '8px', + padding: '1rem 2rem', + marginTop: '3rem', + marginBottom: '3rem', + backgroundColor: '#f8f9fa' + }; + const descriptionStyle = { + fontFamily: 'sans-serif', + color: '#333', + lineHeight: 1.6 + }; + const codeStyle = { + backgroundColor: '#e9ecef', + padding: '2px 6px', + borderRadius: '4px', + fontFamily: 'Roboto' + }; + return (

Visor de Widgets

- + +
+

Widget: Carrusel de Resultados (Home)

+

+ Uso: <HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={2} titulo="Diputados - Provincia de Buenos Aires" /> +

+ +
+ + {/* --- SECCIÓN PARA EL WIDGET DE TARJETAS CON EJEMPLOS --- */} +
+

Widget: Resultados por Provincia (Tarjetas)

+ +
+ +

1. Vista por Defecto

+

+ Sin parámetros adicionales. Muestra todas las provincias, con sus categorías correspondientes (Diputados para las 24, Senadores para las 8 que renuevan). Muestra los 2 principales partidos por defecto. +
+ Uso: <ResultadosNacionalesCardsWidget eleccionId={2} /> +

+ + + + +
+ +

2. Filtrado por Provincia (focoDistritoId)

+

+ Muestra únicamente la tarjeta de una provincia específica. Ideal para páginas de noticias locales. El ID de distrito ("02" para Bs. As., "06" para Chaco) se pasa como prop. +
+ Ejemplo Buenos Aires: <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" /> +

+ + +

+ Ejemplo Chaco (que también renueva Senadores): <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" /> +

+ + +
+ +

3. Filtrado por Categoría (focoCategoriaId)

+

+ Muestra todas las provincias que votan para una categoría específica. +
+ Ejemplo Senadores (ID 1): <ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={1} /> +

+ + +
+ +

4. Indicando Cantidad de Resultados (cantidadResultados)

+

+ Controla cuántos partidos se muestran en cada categoría. Por defecto son 2. +
+ Ejemplo mostrando el TOP 3 de cada categoría: <ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} /> +

+ + + + +
+ +

5. Mostrando las Bancas (mostrarBancas)

+

+ Útil para contextos donde importan las bancas. La prop mostrarBancas se establece en true. +
+ Ejemplo en Tierra del Fuego: <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} /> +

+ + +
+ +

6. Combinación de Parámetros

+

+ Se pueden combinar todos los parámetros para vistas muy específicas. +
+ Ejemplo: Mostrar el TOP 1 (el ganador) para la categoría de SENADORES en la provincia de RÍO NEGRO (Distrito ID "16"). +
+ Uso: <ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={1} cantidadResultados={1} /> +

+ + +
+ + + {/* --- OTROS WIDGETS --- */}
diff --git a/Elecciones-Web/frontend/src/features/legislativas/DevAppStyle.css b/Elecciones-Web/frontend/src/features/legislativas/DevAppStyle.css index 595e266..8af60e8 100644 --- a/Elecciones-Web/frontend/src/features/legislativas/DevAppStyle.css +++ b/Elecciones-Web/frontend/src/features/legislativas/DevAppStyle.css @@ -1,3 +1,50 @@ .container{ text-align: center; +} + +/* --- ESTILOS PARA EL CONTENEDOR COLAPSABLE --- */ + +.collapsible-container { + position: relative; + padding-bottom: 50px; /* Espacio para el botón de expandir */ +} + +.collapsible-content { + max-height: 950px; /* Altura suficiente para 2 filas de tarjetas (aprox) */ + overflow: hidden; + transition: max-height 0.7s ease-in-out; + position: relative; +} + +.collapsible-content.expanded { + max-height: 100%; /* Un valor grande para asegurar que todo el contenido sea visible */ +} + +/* Pseudo-elemento para crear un degradado y sugerir que hay más contenido */ +.collapsible-content:not(.expanded)::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 150px; + background: linear-gradient(to top, rgba(248, 249, 250, 1) 20%, rgba(248, 249, 250, 0)); + pointer-events: none; /* Permite hacer clic a través del degradado */ +} + +.toggle-button { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + font-size: 1rem; + font-weight: bold; + color: #fff; + background-color: #007bff; + border: none; + border-radius: 20px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + z-index: 2; } \ No newline at end of file diff --git a/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.css b/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.css new file mode 100644 index 0000000..c476fcc --- /dev/null +++ b/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.css @@ -0,0 +1,239 @@ +/* src/features/legislativas/nacionales/HomeCarouselWidget.css */ + +.home-carousel-widget { + --primary-text: #212529; + --secondary-text: #6c757d; + --border-color: #dee2e6; + --background-light: #f8f9fa; + --background-white: #ffffff; + --shadow: 0 2px 8px rgba(0, 0, 0, 0.07); + --font-family-sans: "Roboto", system-ui, sans-serif; +} + +.home-carousel-widget { + font-family: var(--font-family-sans); + background-color: var(--background-white); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.75rem; + max-width: 1200px; + margin: 2rem auto; +} + +.widget-title { + font-size: 1.2rem; + font-weight: 900; + color: var(--primary-text); + margin: 0 0 0.5rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + text-align: left; +} + +.top-stats-bar { + display: flex; + justify-content: space-around; + background-color: transparent; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 0.3rem 0.5rem; + margin-bottom: 0.5rem; +} + +.top-stats-bar > div { + display: flex; + align-items: baseline; + gap: 0.5rem; + border-right: 1px solid var(--border-color); + padding: 0 0.5rem; + flex-grow: 1; + justify-content: center; +} +.top-stats-bar > div:last-child { border-right: none; } +.top-stats-bar span { font-size: 0.9rem; color: var(--secondary-text); } +.top-stats-bar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); } + +.candidate-card { + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--background-white); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 0.75rem; + box-shadow: var(--shadow); + height: 100%; + border-left: 5px solid; + border-left-color: var(--candidate-color, #ccc); + position: relative; +} + +.candidate-photo-wrapper { + flex-shrink: 0; + width: 60px; + height: 60px; + border-radius: 8px; + overflow: hidden; + background-color: var(--candidate-color, #e9ecef); +} + +.candidate-photo { + width: 100%; + height: 100%; + object-fit: cover; + box-sizing: border-box; +} + +.candidate-details { + flex-grow: 1; + display: flex; + justify-content: space-between; + align-items: center; + min-width: 0; +} + +.candidate-info { + display: flex; + flex-direction: column; + justify-content: center; + align-items:flex-start; + gap: 0.1rem; + min-width: 0; + margin-right: 0.75rem; +} + +.candidate-name, .party-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + width: 100%; +} +.candidate-name { + font-size: 0.95rem; + text-align: left; + font-weight: 700; + color: var(--primary-text); +} +.party-name { + font-size: 0.8rem; + text-align: left; + text-transform: uppercase; + color: var(--secondary-text); + text-transform: uppercase; +} + +.candidate-results { text-align: right; flex-shrink: 0; } +.percentage { + display: block; + font-size: 1.2rem; + font-weight: 700; + color: var(--primary-text); + line-height: 1.1; +} +.votes { + font-size: 0.75rem; + color: var(--secondary-text); + white-space: nowrap; +} + +.swiper-slide:not(:last-child) .candidate-card::after { + content: ''; + position: absolute; + right: -8px; + top: 20%; + bottom: 20%; + width: 1px; + background-color: var(--border-color); +} + +.swiper-button-prev, .swiper-button-next { + width: 30px; height: 30px; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid var(--border-color); + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + transition: opacity 0.2s; + color: var(--secondary-text); +} +.swiper-button-prev:after, .swiper-button-next:after { + font-size: 18px; + font-weight: bold; +} +.swiper-button-prev { left: -10px; } +.swiper-button-next { right: -10px; } +.swiper-button-disabled { opacity: 0; pointer-events: none; } + +.widget-footer { + text-align: right; + font-size: 0.75rem; + color: var(--secondary-text); + margin-top: 0.5rem; +} + +.short-text { + display: none; /* Oculto por defecto en la vista de escritorio */ +} + +/* --- INICIO DE LA SECCIÓN DE ESTILOS PARA MÓVIL --- */ +@media (max-width: 768px) { + .home-carousel-widget { + padding: 0.75rem; + } + + /* 1. Centrar el título en móvil */ + .widget-title { + text-align: center; + font-size: 1.1rem; + } + + /* 2. Reestructurar la barra de estadísticas a 2x2 y usar textos cortos */ + .top-stats-bar { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.2rem; + padding: 0.3rem; + } + + .top-stats-bar > div { + padding: 0.25rem 0.5rem; + border-right: none; /* Quitar todos los bordes derechos */ + } + + .top-stats-bar > div:nth-child(odd) { + border-right: 1px solid var(--border-color); /* Restablecer borde solo para la columna izquierda */ + } + + /* Lógica de visibilidad de textos */ + .long-text { + display: none; /* Ocultar el texto largo en móvil */ + } + .short-text { + display:inline; /* Mostrar el texto corto en móvil */ + } + + /* Reducir fuentes para que quepan mejor */ + .top-stats-bar span { font-size: 0.8rem; text-align: left; } + .top-stats-bar strong { font-size: 0.85rem; text-align: right;} + + /* --- Botones del Carrusel (sin cambios) --- */ + .swiper-button-prev, .swiper-button-next { + width: 32px; + height: 32px; + top: 45%; + } + .swiper-button-prev { left: 2px; } + .swiper-button-next { right: 2px; } + + /* --- Ajustes en la tarjeta (sin cambios) --- */ + .candidate-card { gap: 0.5rem; padding: 0.5rem; } + .candidate-photo-wrapper { width: 50px; height: 50px; } + .candidate-name { font-size: 0.9rem; } + .percentage { font-size: 1.1rem; } + .votes { font-size: 0.7rem; } + + /* 3. Centrar el footer en móvil */ + .widget-footer { + text-align: center; + } +} \ No newline at end of file diff --git a/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.tsx b/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.tsx new file mode 100644 index 0000000..10ecee8 --- /dev/null +++ b/Elecciones-Web/frontend/src/features/legislativas/nacionales/HomeCarouselWidget.tsx @@ -0,0 +1,135 @@ +// src/features/legislativas/nacionales/HomeCarouselWidget.tsx +import { useQuery } from '@tanstack/react-query'; +import { getHomeResumen } from '../../../apiService'; +import { ImageWithFallback } from '../../../components/common/ImageWithFallback'; +import { assetBaseUrl } from '../../../apiService'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation, A11y } from 'swiper/modules'; + +// @ts-ignore +import 'swiper/css'; +// @ts-ignore +import 'swiper/css/navigation'; +import './HomeCarouselWidget.css'; + +interface Props { + eleccionId: number; + distritoId: string; + categoriaId: number; + titulo: string; +} + +const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`; +const formatNumber = (num: number) => num.toLocaleString('es-AR'); + +// --- Lógica de formateo de fecha --- +const formatDateTime = (dateString: string | undefined | null) => { + if (!dateString) return '...'; + try { + const date = new Date(dateString); + // Verificar si la fecha es válida + if (isNaN(date.getTime())) { + return dateString; // Si no se puede parsear, devolver el string original + } + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${day}/${month}/${year}, ${hours}:${minutes} hs.`; + } catch (e) { + return dateString; // En caso de cualquier error, devolver el string original + } +}; + +export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo }: Props) => { + const { data, isLoading, error } = useQuery({ + queryKey: ['homeResumen', eleccionId, distritoId, categoriaId], + queryFn: () => getHomeResumen(eleccionId, distritoId, categoriaId), + }); + + if (isLoading) return
Cargando widget...
; + if (error || !data) return
No se pudieron cargar los datos.
; + + return ( +
+

{titulo}

+ +
+
+ Participación + {formatPercent(data.estadoRecuento?.participacionPorcentaje)} +
+
+ Mesas escrutadas + Escrutado + {formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)} +
+
+ Votos en blanco + En blanco + {formatPercent(data.votosEnBlancoPorcentaje)} +
+
+ Votos totales + Votos + {formatNumber(data.votosTotales)} +
+
+ + + {data.resultados.map(candidato => ( + +
+ +
+ +
+ +
+
+ {candidato.nombreCandidato ? ( + // CASO 1: Hay un candidato (se muestran dos líneas) + <> + + {candidato.nombreCandidato} + + + {candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion} + + + ) : ( + // CASO 2: No hay candidato (se muestra solo una línea) + + {candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion} + + )} +
+
+ {formatPercent(candidato.porcentaje)} + {formatNumber(candidato.votos)} votos +
+
+ +
+
+ ))} +
+ +
+ Última actualización: {formatDateTime(data.ultimaActualizacion)} +
+
+ ); +}; \ No newline at end of file diff --git a/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacional.css b/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacional.css index bb99c27..f1f9b76 100644 --- a/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacional.css +++ b/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacional.css @@ -22,14 +22,9 @@ /* Contenedor para alinear título y selector */ .header-top-row { display: flex; - justify-content: space-between; + justify-content: flex-start; /* Alinea los items al inicio */ align-items: center; - margin-bottom: 0.5rem; -} - -.panel-header h1 { - font-size: 1.5rem; - margin: 0; + gap: 2rem; /* Añade un espacio de separación de 2rem entre el selector y el breadcrumb */ } .categoria-selector { @@ -188,6 +183,7 @@ padding: 1rem 0; border-bottom: 1px solid #f0f0f0; border-left: 5px solid; + border-radius: 12px; padding-left: 1rem; } @@ -227,18 +223,25 @@ .partido-info-wrapper { /* Ocupa el espacio disponible a la izquierda */ min-width: 0; + text-align: left; } .partido-nombre { - font-weight: 800; + font-weight: 700; + font-size: 1.05rem; + color: #212529; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.2; } .candidato-nombre { - font-size: 0.85rem; - color: #666; + font-size: 0.8rem; + color: #6c757d; + text-transform: uppercase; + font-weight: 500; + line-height: 1.1; } .partido-stats { @@ -381,10 +384,13 @@ } .rsm-geography:not(.selected):hover { - filter: brightness(1.25); /* Mantenemos el brillo */ - stroke: #ffffff; /* Color del borde a blanco */ + filter: brightness(1.25); + /* Mantenemos el brillo */ + stroke: #ffffff; + /* Color del borde a blanco */ stroke-width: 0.25px; - paint-order: stroke; /* Asegura que el borde se dibuje encima del relleno */ + paint-order: stroke; + /* Asegura que el borde se dibuje encima del relleno */ } .rsm-geography.selected { @@ -492,8 +498,10 @@ /* --- NUEVOS ESTILOS PARA EL TOGGLE MÓVIL --- */ .mobile-view-toggle { display: none; - position: absolute; /* <-- CAMBIO: De 'fixed' a 'absolute' */ - bottom: 10px; /* <-- AJUSTE: Menos espacio desde abajo */ + position: absolute; + /* <-- CAMBIO: De 'fixed' a 'absolute' */ + bottom: 10px; + /* <-- AJUSTE: Menos espacio desde abajo */ left: 50%; transform: translateX(-50%); z-index: 100; @@ -685,6 +693,14 @@ display: flex; align-items: center; gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid #f0f0f0; + border-left: 5px solid; + /* Grosor del borde */ + border-radius: 12px; + /* Redondeamos las esquinas */ + padding-left: 1rem; + /* Espacio a la izquierda */ } .partido-logo { diff --git a/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacionalWidget.tsx b/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacionalWidget.tsx index 0b1d824..6be3407 100644 --- a/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacionalWidget.tsx +++ b/Elecciones-Web/frontend/src/features/legislativas/nacionales/PanelNacionalWidget.tsx @@ -10,6 +10,7 @@ import Select from 'react-select'; import type { PanelElectoralDto } from '../../../types/types'; import { FiMap, FiList } from 'react-icons/fi'; import { useMediaQuery } from './hooks/useMediaQuery'; +import { Toaster } from 'react-hot-toast'; interface PanelNacionalWidgetProps { eleccionId: number; @@ -79,9 +80,9 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => return (
+
-

Legislativas Argentina 2025