Compare commits
69 Commits
11d9417ef5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 63cc5ecec8 | |||
| 3a43c4a74a | |||
| ef1c1e41dc | |||
| c36f4b6153 | |||
| 99406d10ee | |||
| 8d7f5c1db6 | |||
| 21002445b2 | |||
| 70069d46f7 | |||
| ad883257a3 | |||
| 1335b54d75 | |||
| 983ed5e39c | |||
| e98e152f0e | |||
| 248171146d | |||
| 4dbda0da63 | |||
| 3c364ef373 | |||
| 814b24cefb | |||
| f89903feda | |||
| 0ee092d6ed | |||
| db469ffba6 | |||
| 5ef3eb1af2 | |||
| bea752f7d0 | |||
| a0e587d8b5 | |||
| ced1ae6b3f | |||
| c5c1872ab8 | |||
| c50e4210b5 | |||
| 4cefb833d9 | |||
| a78fcf66c0 | |||
| 99d56033b1 | |||
| 5c11763386 | |||
| 9cd91581bf | |||
| d6b4c3cc4d | |||
| 069446b903 | |||
| 2b7fb927e2 | |||
| 705683861c | |||
| 17a5b333fd | |||
| ae846f2d48 | |||
| 4bc257df43 | |||
| 6892252a9b | |||
| 92c80f195b | |||
| 45421f5c5f | |||
| 903c2b6a94 | |||
| 7317c06650 | |||
| fca65edefc | |||
| 6cd09343f2 | |||
| 09c4d61b71 | |||
| 705a6f0f5e | |||
| 316f49f25b | |||
| 84f7643907 | |||
| 2736301338 | |||
| a316e5dd08 | |||
| ce4fc52d4a | |||
| fa261ba828 | |||
| 3c8c4917fd | |||
| 68f31f2873 | |||
| 9e0e7f0ee6 | |||
| b8c8c1260d | |||
| 64d45a7a39 | |||
| 1719e79723 | |||
| e0755a5347 | |||
| e9b0eeb630 | |||
| 63cc042eb4 | |||
| ed5b78e6c8 | |||
| a985cbfd7c | |||
| 3b0eee25e6 | |||
| 67634ae947 | |||
| 5a8bee52d5 | |||
| 3750d1a56d | |||
| 7d2922aaeb | |||
| 3a8f64bf85 |
1
.gitignore
vendored
@@ -28,6 +28,7 @@ build/
|
||||
*.userprefs
|
||||
/bin/
|
||||
/obj/
|
||||
/debug/
|
||||
project.lock.json
|
||||
project.assets.json
|
||||
/packages/
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/components/AddAgrupacionForm.tsx
|
||||
import { useState } from 'react';
|
||||
import { createAgrupacion } from '../services/apiService';
|
||||
import type { CreateAgrupacionData } from '../services/apiService';
|
||||
// Importa el nuevo archivo CSS si lo creaste, o el existente
|
||||
import './FormStyles.css';
|
||||
|
||||
interface Props {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const AddAgrupacionForm = ({ onSuccess }: Props) => {
|
||||
const [nombre, setNombre] = useState('');
|
||||
const [nombreCorto, setNombreCorto] = useState('');
|
||||
const [color, setColor] = useState('#000000');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nombre.trim()) {
|
||||
setError('El nombre es obligatorio.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
const payload: CreateAgrupacionData = {
|
||||
nombre: nombre.trim(),
|
||||
nombreCorto: nombreCorto.trim() || null,
|
||||
color: color,
|
||||
};
|
||||
|
||||
try {
|
||||
await createAgrupacion(payload);
|
||||
alert(`Partido '${payload.nombre}' creado con éxito.`);
|
||||
// Limpiar formulario
|
||||
setNombre('');
|
||||
setNombreCorto('');
|
||||
setColor('#000000');
|
||||
// Notificar al componente padre para que refresque los datos
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || 'Ocurrió un error inesperado.';
|
||||
setError(errorMessage);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="add-entity-form-container">
|
||||
<h4>Añadir Partido Manualmente</h4>
|
||||
<form onSubmit={handleSubmit} className="add-entity-form">
|
||||
|
||||
<div className="form-field">
|
||||
<label>Nombre Completo</label>
|
||||
<input type="text" value={nombre} onChange={e => setNombre(e.target.value)} required />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Nombre Corto</label>
|
||||
<input type="text" value={nombreCorto} onChange={e => setNombreCorto(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Color</label>
|
||||
<input type="color" value={color} onChange={e => setColor(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Guardando...' : 'Guardar Partido'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p style={{ color: 'red', marginTop: '0.5rem', textAlign: 'left' }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -36,6 +36,23 @@ td button {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
max-height: 500px; /* Altura máxima antes de que aparezca el scroll */
|
||||
overflow-y: auto; /* Habilita el scroll vertical cuando es necesario */
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
position: relative; /* Necesario para que 'sticky' funcione correctamente */
|
||||
}
|
||||
|
||||
/* Hacemos que la cabecera de la tabla se quede fija en la parte superior */
|
||||
.table-container thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
/* El color de fondo es crucial para que no se vea el contenido que pasa por debajo */
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
|
||||
.sortable-list-horizontal {
|
||||
list-style: none;
|
||||
padding: 8px;
|
||||
|
||||
@@ -1,174 +1,154 @@
|
||||
// src/components/AgrupacionesManager.tsx
|
||||
// EN: src/components/AgrupacionesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService';
|
||||
import type { AgrupacionPolitica, LogoAgrupacionCategoria } from '../types';
|
||||
import type { AgrupacionPolitica, LogoAgrupacionCategoria, UpdateAgrupacionData } from '../types';
|
||||
import { AddAgrupacionForm } from './AddAgrupacionForm';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const SENADORES_ID = 5;
|
||||
const DIPUTADOS_ID = 6;
|
||||
const CONCEJALES_ID = 7;
|
||||
const GLOBAL_ELECTION_ID = 0;
|
||||
|
||||
const ELECCION_OPTIONS = [
|
||||
{ value: GLOBAL_ELECTION_ID, label: 'Global (Logo por Defecto)' },
|
||||
{ value: 2, label: 'Elecciones Nacionales (Override General)' },
|
||||
{ value: 1, label: 'Elecciones Provinciales (Override General)' }
|
||||
];
|
||||
|
||||
// Esta función limpia cualquier carácter no válido de un string de color.
|
||||
const sanitizeColor = (color: string | null | undefined): string => {
|
||||
if (!color) return '#000000'; // Devuelve un color válido por defecto si es nulo
|
||||
// Usa una expresión regular para eliminar todo lo que no sea un '#' o un carácter hexadecimal
|
||||
const sanitized = color.replace(/[^#0-9a-fA-F]/g, '');
|
||||
return sanitized.startsWith('#') ? sanitized : `#${sanitized}`;
|
||||
if (!color) return '#000000';
|
||||
return color.startsWith('#') ? color : `#${color}`;
|
||||
};
|
||||
|
||||
export const AgrupacionesManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, { nombreCorto: string | null; color: string | null; }>>({});
|
||||
const [editedLogos, setEditedLogos] = useState<Record<string, string | null>>({});
|
||||
|
||||
const [editedAgrupaciones, setEditedAgrupaciones] = useState<Record<string, Partial<AgrupacionPolitica>>>({});
|
||||
const [editedLogos, setEditedLogos] = useState<LogoAgrupacionCategoria[]>([]);
|
||||
|
||||
// Query 1: Obtener agrupaciones
|
||||
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
queryKey: ['agrupaciones'], queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
// Query 2: Obtener logos
|
||||
const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({
|
||||
queryKey: ['logos'],
|
||||
queryFn: getLogos,
|
||||
queryKey: ['allLogos'],
|
||||
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Solo procedemos si los datos de agrupaciones están disponibles
|
||||
if (agrupaciones && agrupaciones.length > 0) {
|
||||
// Inicializamos el estado de 'editedAgrupaciones' una sola vez.
|
||||
// Usamos una función en setState para asegurarnos de que solo se ejecute
|
||||
// si el estado está vacío, evitando sobreescribir ediciones del usuario.
|
||||
setEditedAgrupaciones(prev => {
|
||||
if (Object.keys(prev).length === 0) {
|
||||
return Object.fromEntries(agrupaciones.map(a => [a.id, {}]));
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Hacemos lo mismo para los logos
|
||||
if (logos && logos.length > 0) {
|
||||
setEditedLogos(prev => {
|
||||
if (prev.length === 0) {
|
||||
// Creamos una copia profunda para evitar mutaciones accidentales
|
||||
return JSON.parse(JSON.stringify(logos));
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
// La dependencia ahora es el estado de carga. El hook se ejecutará cuando
|
||||
// isLoadingAgrupaciones o isLoadingLogos cambien de true a false.
|
||||
}, [agrupaciones, logos]);
|
||||
|
||||
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => {
|
||||
setEditedAgrupaciones(prev => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], [field]: value }
|
||||
}));
|
||||
const handleCreationSuccess = () => {
|
||||
// Invalida la query de agrupaciones para forzar una actualización
|
||||
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
|
||||
};
|
||||
|
||||
const handleLogoChange = (agrupacionId: string, categoriaId: number, value: string) => {
|
||||
setEditedLogos(prev => {
|
||||
const newLogos = [...prev];
|
||||
const existing = newLogos.find(l =>
|
||||
l.agrupacionPoliticaId === agrupacionId &&
|
||||
l.categoriaId === categoriaId &&
|
||||
l.ambitoGeograficoId == null
|
||||
useEffect(() => {
|
||||
if (agrupaciones.length > 0) {
|
||||
const initialEdits = Object.fromEntries(
|
||||
agrupaciones.map(a => [a.id, { nombreCorto: a.nombreCorto, color: a.color }])
|
||||
);
|
||||
setEditedAgrupaciones(initialEdits);
|
||||
}
|
||||
}, [agrupaciones]);
|
||||
|
||||
if (existing) {
|
||||
existing.logoUrl = value;
|
||||
} else {
|
||||
newLogos.push({
|
||||
id: 0,
|
||||
agrupacionPoliticaId: agrupacionId,
|
||||
categoriaId,
|
||||
logoUrl: value,
|
||||
ambitoGeograficoId: null
|
||||
});
|
||||
}
|
||||
return newLogos;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (logos) {
|
||||
const logoMap = Object.fromEntries(
|
||||
logos
|
||||
// --- CORRECCIÓN CLAVE 1: Comprobar contra `null` en lugar de `0` ---
|
||||
.filter(l => l.categoriaId === 0 && l.ambitoGeograficoId === null)
|
||||
.map(l => [`${l.agrupacionPoliticaId}-${l.eleccionId}`, l.logoUrl])
|
||||
);
|
||||
setEditedLogos(logoMap);
|
||||
}
|
||||
}, [logos]);
|
||||
|
||||
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string | null) => {
|
||||
setEditedAgrupaciones(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }));
|
||||
};
|
||||
|
||||
const handleLogoInputChange = (agrupacionId: string, value: string | null) => {
|
||||
const key = `${agrupacionId}-${selectedEleccion.value}`;
|
||||
setEditedLogos(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
const agrupacionPromises = Object.entries(editedAgrupaciones).map(([id, changes]) => {
|
||||
if (Object.keys(changes).length > 0) {
|
||||
const original = agrupaciones.find(a => a.id === id);
|
||||
if (original) { // Chequeo de seguridad
|
||||
return updateAgrupacion(id, { ...original, ...changes });
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
const agrupacionPromises = agrupaciones.map(agrupacion => {
|
||||
const changes = editedAgrupaciones[agrupacion.id] || {};
|
||||
const payload: UpdateAgrupacionData = {
|
||||
nombreCorto: changes.nombreCorto ?? agrupacion.nombreCorto,
|
||||
color: changes.color ?? agrupacion.color,
|
||||
};
|
||||
return updateAgrupacion(agrupacion.id, payload);
|
||||
});
|
||||
|
||||
const logoPromise = updateLogos(editedLogos);
|
||||
// --- CORRECCIÓN CLAVE 2: Enviar `null` a la API en lugar de `0` ---
|
||||
const logosPayload = Object.entries(editedLogos)
|
||||
.map(([key, logoUrl]) => {
|
||||
const [agrupacionPoliticaId, eleccionIdStr] = key.split('-');
|
||||
return { id: 0, eleccionId: parseInt(eleccionIdStr), agrupacionPoliticaId, categoriaId: 0, logoUrl: logoUrl || null, ambitoGeograficoId: null };
|
||||
});
|
||||
|
||||
const logoPromise = updateLogos(logosPayload);
|
||||
|
||||
await Promise.all([...agrupacionPromises, logoPromise]);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logos'] });
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['allLogos'] });
|
||||
alert('¡Todos los cambios han sido guardados!');
|
||||
} catch (err) {
|
||||
console.error("Error al guardar todo:", err);
|
||||
alert("Ocurrió un error al guardar los cambios.");
|
||||
}
|
||||
} catch (err) { console.error("Error al guardar todo:", err); alert("Ocurrió un error."); }
|
||||
};
|
||||
|
||||
const getLogoValue = (agrupacionId: string): string => {
|
||||
const key = `${agrupacionId}-${selectedEleccion.value}`;
|
||||
return editedLogos[key] ?? '';
|
||||
};
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingLogos;
|
||||
|
||||
const getLogoUrl = (agrupacionId: string, categoriaId: number) => {
|
||||
return editedLogos.find(l =>
|
||||
l.agrupacionPoliticaId === agrupacionId &&
|
||||
l.categoriaId === categoriaId &&
|
||||
l.ambitoGeograficoId == null
|
||||
)?.logoUrl || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Gestión de Agrupaciones y Logos</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3>Gestión de Agrupaciones y Logos</h3>
|
||||
<div style={{ width: '350px', zIndex: 100 }}>
|
||||
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => setSelectedEleccion(opt!)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <p>Cargando...</p> : (
|
||||
<>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Nombre Corto</th>
|
||||
<th>Color</th>
|
||||
<th>Logo Senadores</th>
|
||||
<th>Logo Diputados</th>
|
||||
<th>Logo Concejales</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>{agrupacion.nombre}</td>
|
||||
<td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? agrupacion.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td>
|
||||
<td>
|
||||
<input
|
||||
type="color"
|
||||
// Usamos la función sanitizeColor para asegurarnos de que el valor sea siempre válido
|
||||
value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color)}
|
||||
onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, SENADORES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, SENADORES_ID, e.target.value)} /></td>
|
||||
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, DIPUTADOS_ID)} onChange={(e) => handleLogoChange(agrupacion.id, DIPUTADOS_ID, e.target.value)} /></td>
|
||||
<td><input type="text" placeholder="URL de la imagen" value={getLogoUrl(agrupacion.id, CONCEJALES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, CONCEJALES_ID, e.target.value)} /></td>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Nombre Corto</th>
|
||||
<th>Color</th>
|
||||
<th>Logo</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>({agrupacion.id}) {agrupacion.nombre}</td>
|
||||
<td><input type="text" value={editedAgrupaciones[agrupacion.id]?.nombreCorto ?? ''} onChange={(e) => handleInputChange(agrupacion.id, 'nombreCorto', e.target.value)} /></td>
|
||||
<td><input type="color" value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color)} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} /></td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL..."
|
||||
value={getLogoValue(agrupacion.id)}
|
||||
onChange={(e) => handleLogoInputChange(agrupacion.id, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={handleSaveAll} style={{ marginTop: '1rem' }}>
|
||||
Guardar Todos los Cambios
|
||||
</button>
|
||||
<AddAgrupacionForm onSuccess={handleCreationSuccess} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// src/components/BancasNacionalesManager.tsx
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService';
|
||||
import type { Bancada, AgrupacionPolitica } from '../types';
|
||||
import { OcupantesModal } from './OcupantesModal';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
const camaras = ['diputados', 'senadores'] as const;
|
||||
|
||||
export const BancasNacionalesManager = () => {
|
||||
const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados');
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones
|
||||
});
|
||||
|
||||
const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({
|
||||
queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getBancadas(activeTab, ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => {
|
||||
const bancadaActual = bancadas.find(b => b.id === bancadaId);
|
||||
if (!bancadaActual) return;
|
||||
|
||||
const payload: UpdateBancadaData = {
|
||||
agrupacionPoliticaId: nuevaAgrupacionId,
|
||||
nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null,
|
||||
fotoUrl: nuevaAgrupacionId ? (bancadaActual.ocupante?.fotoUrl ?? null) : null,
|
||||
periodo: nuevaAgrupacionId ? (bancadaActual.ocupante?.periodo ?? null) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
await updateBancada(bancadaId, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL] });
|
||||
} catch (err) {
|
||||
alert("Error al guardar el cambio de agrupación.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (bancada: Bancada) => {
|
||||
setBancadaSeleccionada(bancada);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas nacionales.</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Gestión de Bancas (Nacionales)</h3>
|
||||
<p>Asigne partidos y ocupantes a las bancas del Congreso de la Nación.</p>
|
||||
|
||||
<div className="chamber-tabs">
|
||||
{camaras.map(camara => (
|
||||
<button
|
||||
key={camara}
|
||||
className={activeTab === camara ? 'active' : ''}
|
||||
onClick={() => setActiveTab(camara)}
|
||||
>
|
||||
{camara === 'diputados' ? 'Diputados Nacionales (257)' : 'Senadores Nacionales (72)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p>Cargando bancas...</p> : (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Banca #</th>
|
||||
<th style={{ width: '35%' }}>Partido Asignado</th>
|
||||
<th style={{ width: '30%' }}>Ocupante Actual</th>
|
||||
<th style={{ width: '20%' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bancadas.map((bancada) => (
|
||||
<tr key={bancada.id}>
|
||||
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
|
||||
<td>
|
||||
<select
|
||||
value={bancada.agrupacionPoliticaId || ''}
|
||||
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
|
||||
>
|
||||
<option value="">-- Vacante --</option>
|
||||
{agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
|
||||
<td>
|
||||
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
|
||||
Editar Ocupante
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalVisible && bancadaSeleccionada && (
|
||||
<OcupantesModal
|
||||
bancada={bancadaSeleccionada}
|
||||
onClose={() => setModalVisible(false)}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// src/components/BancasPreviasManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getBancasPrevias, updateBancasPrevias, getAgrupaciones } from '../services/apiService';
|
||||
import type { BancaPrevia, AgrupacionPolitica } from '../types';
|
||||
import { TipoCamara } from '../types';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const BancasPreviasManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [editedBancas, setEditedBancas] = useState<Record<string, Partial<BancaPrevia>>>({});
|
||||
|
||||
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
const { data: bancasPrevias = [], isLoading: isLoadingBancas } = useQuery<BancaPrevia[]>({
|
||||
queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getBancasPrevias(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (agrupaciones.length > 0) {
|
||||
const initialData: Record<string, Partial<BancaPrevia>> = {};
|
||||
agrupaciones.forEach(agrupacion => {
|
||||
// Para Diputados
|
||||
const keyDip = `${agrupacion.id}-${TipoCamara.Diputados}`;
|
||||
const existingDip = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Diputados);
|
||||
initialData[keyDip] = { cantidad: existingDip?.cantidad || 0 };
|
||||
|
||||
// Para Senadores
|
||||
const keySen = `${agrupacion.id}-${TipoCamara.Senadores}`;
|
||||
const existingSen = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Senadores);
|
||||
initialData[keySen] = { cantidad: existingSen?.cantidad || 0 };
|
||||
});
|
||||
setEditedBancas(initialData);
|
||||
}
|
||||
}, [agrupaciones, bancasPrevias]);
|
||||
|
||||
const handleInputChange = (agrupacionId: string, camara: typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, value: string) => {
|
||||
const key = `${agrupacionId}-${camara}`;
|
||||
const cantidad = parseInt(value, 10);
|
||||
setEditedBancas(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], cantidad: isNaN(cantidad) ? 0 : cantidad }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload: BancaPrevia[] = Object.entries(editedBancas)
|
||||
.map(([key, value]) => {
|
||||
const [agrupacionPoliticaId, camara] = key.split('-');
|
||||
return {
|
||||
id: 0,
|
||||
eleccionId: ELECCION_ID_NACIONAL,
|
||||
agrupacionPoliticaId,
|
||||
camara: parseInt(camara) as typeof TipoCamara.Diputados | typeof TipoCamara.Senadores,
|
||||
cantidad: value.cantidad || 0,
|
||||
};
|
||||
})
|
||||
.filter(b => b.cantidad > 0);
|
||||
|
||||
try {
|
||||
await updateBancasPrevias(ELECCION_ID_NACIONAL, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL] });
|
||||
alert('Bancas previas guardadas con éxito.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al guardar las bancas previas.');
|
||||
}
|
||||
};
|
||||
|
||||
const totalDiputados = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Diputados}`) ? sum + (value.cantidad || 0) : sum, 0);
|
||||
const totalSenadores = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Senadores}`) ? sum + (value.cantidad || 0) : sum, 0);
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingBancas;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Gestión de Bancas Previas (Composición Nacional)</h3>
|
||||
<p>Define cuántas bancas retiene cada partido antes de la elección. Estos son los escaños que **no** están en juego.</p>
|
||||
{isLoading ? <p>Cargando...</p> : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agrupación Política</th>
|
||||
<th>Bancas Previas Diputados (Total: {totalDiputados} / 130)</th>
|
||||
<th>Bancas Previas Senadores (Total: {totalSenadores} / 48)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>({agrupacion.id}) {agrupacion.nombre}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editedBancas[`${agrupacion.id}-${TipoCamara.Diputados}`]?.cantidad || 0}
|
||||
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Diputados, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editedBancas[`${agrupacion.id}-${TipoCamara.Senadores}`]?.cantidad || 0}
|
||||
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Senadores, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={handleSave} style={{ marginTop: '1rem' }}>Guardar Bancas Previas</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/BancasManager.tsx
|
||||
// src/components/BancasProvincialesManager.tsx
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService';
|
||||
@@ -6,9 +6,10 @@ import type { Bancada, AgrupacionPolitica } from '../types';
|
||||
import { OcupantesModal } from './OcupantesModal';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_PROVINCIAL = 1;
|
||||
const camaras = ['diputados', 'senadores'] as const;
|
||||
|
||||
export const BancasManager = () => {
|
||||
export const BancasProvincialesManager = () => {
|
||||
const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados');
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null);
|
||||
@@ -19,16 +20,18 @@ export const BancasManager = () => {
|
||||
queryFn: getAgrupaciones
|
||||
});
|
||||
|
||||
// --- CORRECCIÓN CLAVE ---
|
||||
// 1. La queryKey ahora incluye el eleccionId para ser única.
|
||||
// 2. La función queryFn ahora pasa el ELECCION_ID_PROVINCIAL a getBancadas.
|
||||
const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({
|
||||
queryKey: ['bancadas', activeTab],
|
||||
queryFn: () => getBancadas(activeTab),
|
||||
queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL],
|
||||
queryFn: () => getBancadas(activeTab, ELECCION_ID_PROVINCIAL),
|
||||
});
|
||||
|
||||
const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => {
|
||||
const bancadaActual = bancadas.find(b => b.id === bancadaId);
|
||||
if (!bancadaActual) return;
|
||||
|
||||
// Si se desasigna el partido (vacante), también se limpia el ocupante
|
||||
const payload: UpdateBancadaData = {
|
||||
agrupacionPoliticaId: nuevaAgrupacionId,
|
||||
nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null,
|
||||
@@ -38,7 +41,7 @@ export const BancasManager = () => {
|
||||
|
||||
try {
|
||||
await updateBancada(bancadaId, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab] });
|
||||
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_PROVINCIAL] });
|
||||
} catch (err) {
|
||||
alert("Error al guardar el cambio de agrupación.");
|
||||
}
|
||||
@@ -49,12 +52,12 @@ export const BancasManager = () => {
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas.</p>;
|
||||
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas provinciales.</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h2>Gestión de Ocupación de Bancas</h2>
|
||||
<p>Asigne a cada banca física un partido político y, opcionalmente, los datos de la persona que la ocupa.</p>
|
||||
<h3>Gestión de Bancas (Provinciales)</h3>
|
||||
<p>Asigne partidos y ocupantes a las bancas de la legislatura provincial.</p>
|
||||
|
||||
<div className="chamber-tabs">
|
||||
{camaras.map(camara => (
|
||||
@@ -63,7 +66,7 @@ export const BancasManager = () => {
|
||||
className={activeTab === camara ? 'active' : ''}
|
||||
onClick={() => setActiveTab(camara)}
|
||||
>
|
||||
{camara === 'diputados' ? 'Cámara de Diputados' : 'Cámara de Senadores'}
|
||||
{camara === 'diputados' ? 'Diputados Provinciales (92)' : 'Senadores Provinciales (46)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -81,32 +84,19 @@ export const BancasManager = () => {
|
||||
<tbody>
|
||||
{bancadas.map((bancada) => (
|
||||
<tr key={bancada.id}>
|
||||
{/* Usamos el NumeroBanca para la etiqueta visual */}
|
||||
<td>
|
||||
{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}
|
||||
{((activeTab === 'diputados' && bancada.numeroBanca === 92) ||
|
||||
(activeTab === 'senadores' && bancada.numeroBanca === 46)) && (
|
||||
<span style={{ marginLeft: '8px', fontSize: '0.8em', color: '#666', fontStyle: 'italic' }}>
|
||||
(Presidencia)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
|
||||
<td>
|
||||
<select
|
||||
value={bancada.agrupacionPoliticaId || ''}
|
||||
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
|
||||
>
|
||||
<option value="">-- Vacante --</option>
|
||||
{agrupaciones.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
|
||||
{agrupaciones.map(a => <option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
|
||||
<td>
|
||||
<button
|
||||
// El botón se habilita solo si hay un partido asignado a la banca
|
||||
disabled={!bancada.agrupacionPoliticaId}
|
||||
onClick={() => handleOpenModal(bancada)}
|
||||
>
|
||||
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
|
||||
Editar Ocupante
|
||||
</button>
|
||||
</td>
|
||||
@@ -1,71 +1,81 @@
|
||||
// src/components/CandidatoOverridesManager.tsx
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService';
|
||||
import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride } from '../types';
|
||||
import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandidatos, updateCandidatos } from '../services/apiService';
|
||||
import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types';
|
||||
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
|
||||
|
||||
const CATEGORIAS_OPTIONS = [
|
||||
{ value: 5, label: 'Senadores' },
|
||||
{ value: 6, label: 'Diputados' },
|
||||
{ value: 7, label: 'Concejales' }
|
||||
const ELECCION_OPTIONS = [
|
||||
{ value: 0, label: 'General (Todas las elecciones)' },
|
||||
{ value: 2, label: 'Elecciones Nacionales' },
|
||||
{ value: 1, label: 'Elecciones Provinciales' }
|
||||
];
|
||||
|
||||
const AMBITO_LEVEL_OPTIONS = [
|
||||
{ value: 'general', label: 'General (Toda la elección)' },
|
||||
{ value: 'provincia', label: 'Por Provincia' },
|
||||
{ value: 'municipio', label: 'Por Municipio' }
|
||||
];
|
||||
|
||||
export const CandidatoOverridesManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
|
||||
const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({ queryKey: ['candidatos'], queryFn: getCandidatos });
|
||||
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
|
||||
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null);
|
||||
const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
|
||||
const [nombreCandidato, setNombreCandidato] = useState('');
|
||||
|
||||
const municipioOptions = useMemo(() =>
|
||||
// Añadimos la opción "General" que representará un ámbito nulo
|
||||
[{ value: 'general', label: 'General (Todos los Municipios)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))]
|
||||
, [municipios]);
|
||||
const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin });
|
||||
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
|
||||
|
||||
const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]);
|
||||
const { data: candidatos = [] } = useQuery<CandidatoOverride[]>({
|
||||
queryKey: ['allCandidatos'],
|
||||
queryFn: () => Promise.all([getCandidatos(0), getCandidatos(1), getCandidatos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
const categoriaOptions = useMemo(() => {
|
||||
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
|
||||
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
|
||||
}, [selectedEleccion]);
|
||||
|
||||
const getAmbitoId = () => {
|
||||
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
|
||||
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentCandidato = useMemo(() => {
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
|
||||
// Determina si estamos buscando un override general (null) o específico (ID numérico)
|
||||
const ambitoIdBuscado = selectedMunicipio?.value === 'general' ? null : (selectedMunicipio ? parseInt(selectedMunicipio.value) : undefined);
|
||||
|
||||
// Si no se ha seleccionado un municipio, no buscamos nada
|
||||
if (ambitoIdBuscado === undefined) return '';
|
||||
|
||||
const ambitoId = getAmbitoId();
|
||||
return candidatos.find(c =>
|
||||
c.ambitoGeograficoId === ambitoIdBuscado &&
|
||||
c.agrupacionPoliticaId === selectedAgrupacion.value &&
|
||||
c.eleccionId === selectedEleccion.value &&
|
||||
c.ambitoGeograficoId === ambitoId &&
|
||||
c.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
c.categoriaId === selectedCategoria.value
|
||||
)?.nombreCandidato || '';
|
||||
}, [candidatos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
}, [candidatos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setNombreCandidato(currentCandidato) }, [currentCandidato]);
|
||||
useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
|
||||
|
||||
const ambitoIdParaEnviar = selectedMunicipio.value === 'general'
|
||||
? null
|
||||
: parseInt(selectedMunicipio.value);
|
||||
|
||||
if (!selectedAgrupacion || !selectedCategoria) return;
|
||||
const newCandidatoEntry: CandidatoOverride = {
|
||||
id: 0, // El backend no lo necesita para el upsert
|
||||
agrupacionPoliticaId: selectedAgrupacion.value,
|
||||
id: 0,
|
||||
eleccionId: selectedEleccion.value,
|
||||
agrupacionPoliticaId: selectedAgrupacion.id,
|
||||
categoriaId: selectedCategoria.value,
|
||||
ambitoGeograficoId: ambitoIdParaEnviar,
|
||||
nombreCandidato: nombreCandidato || null
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
nombreCandidato: nombreCandidato.trim() || null
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCandidatos([newCandidatoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['candidatos'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allCandidatos'] });
|
||||
alert('Override de candidato guardado.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -76,21 +86,30 @@ export const CandidatoOverridesManager = () => {
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Overrides de Nombres de Candidatos</h3>
|
||||
<p>Configure un nombre de candidato específico para un partido, categoría y municipio (o general).</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<p>Configure un nombre de candidato específico para un partido en un contexto determinado.</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}>
|
||||
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} />
|
||||
<Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." />
|
||||
<Select
|
||||
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
|
||||
getOptionValue={opt => opt.id}
|
||||
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
|
||||
value={selectedAgrupacion}
|
||||
onChange={setSelectedAgrupacion}
|
||||
placeholder="Seleccione Agrupación..."
|
||||
/>
|
||||
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
|
||||
|
||||
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
|
||||
) : <div />}
|
||||
|
||||
{selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
|
||||
) : <div />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Categoría</label>
|
||||
<Select options={CATEGORIAS_OPTIONS} value={selectedCategoria} onChange={setSelectedCategoria} isClearable placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Municipio (Opcional)</label>
|
||||
<Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="General..."/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Agrupación</label>
|
||||
<Select options={agrupacionOptions} value={selectedAgrupacion} onChange={setSelectedAgrupacion} isClearable placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label>Nombre del Candidato</label>
|
||||
<input type="text" value={nombreCandidato} onChange={e => setNombreCandidato(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// src/components/ConfiguracionNacional.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getAgrupaciones, getConfiguracion, updateConfiguracion } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
export const ConfiguracionNacional = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [presidenciaDiputadosId, setPresidenciaDiputadosId] = useState<string>('');
|
||||
const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>('');
|
||||
const [modoOficialActivo, setModoOficialActivo] = useState(false);
|
||||
const [diputadosTipoBanca, setDiputadosTipoBanca] = useState<'ganada' | 'previa'>('ganada');
|
||||
// El estado para el tipo de banca del senado ya no es necesario para la UI,
|
||||
// pero lo mantenemos para no romper el handleSave.
|
||||
const [senadoTipoBanca, setSenadoTipoBanca] = useState<'ganada' | 'previa'>('ganada');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]);
|
||||
setAgrupaciones(agrupacionesData);
|
||||
setPresidenciaDiputadosId(configData.PresidenciaDiputadosNacional || '');
|
||||
setPresidenciaSenadoId(configData.PresidenciaSenadoNacional || '');
|
||||
setModoOficialActivo(configData.UsarDatosOficialesNacionales === 'true');
|
||||
setDiputadosTipoBanca(configData.PresidenciaDiputadosNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
|
||||
setSenadoTipoBanca(configData.PresidenciaSenadoNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
|
||||
} catch (err) { console.error("Error al cargar datos de configuración nacional:", err); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateConfiguracion({
|
||||
"PresidenciaDiputadosNacional": presidenciaDiputadosId,
|
||||
"PresidenciaSenadoNacional": presidenciaSenadoId,
|
||||
"UsarDatosOficialesNacionales": modoOficialActivo.toString(),
|
||||
"PresidenciaDiputadosNacional_TipoBanca": diputadosTipoBanca,
|
||||
// Aunque no se muestre, guardamos el valor para consistencia
|
||||
"PresidenciaSenadoNacional_TipoBanca": senadoTipoBanca,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['composicionNacional'] });
|
||||
alert('Configuración nacional guardada.');
|
||||
} catch { alert('Error al guardar.'); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="admin-module"><p>Cargando...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Configuración de Widgets Nacionales</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}>
|
||||
{/* Columna Diputados */}
|
||||
<div style={{ flex: 1, borderRight: '1px solid #ccc', paddingRight: '1rem' }}>
|
||||
<label htmlFor="presidencia-diputados-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
Presidencia Cámara de Diputados
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
|
||||
Este escaño es parte de los 257 diputados y se descuenta del total del partido.
|
||||
</p>
|
||||
<select id="presidencia-diputados-nacional" value={presidenciaDiputadosId} onChange={e => setPresidenciaDiputadosId(e.target.value)} style={{ width: '100%', padding: '8px', marginBottom: '0.5rem' }}>
|
||||
<option value="">-- No Asignado --</option>
|
||||
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))}
|
||||
</select>
|
||||
{presidenciaDiputadosId && (
|
||||
<div>
|
||||
<label><input type="radio" value="ganada" checked={diputadosTipoBanca === 'ganada'} onChange={() => setDiputadosTipoBanca('ganada')} /> Descontar de Banca Ganada</label>
|
||||
<label style={{ marginLeft: '1rem' }}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columna Senadores */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="presidencia-senado-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
Presidencia Senado (Vicepresidente)
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
|
||||
Este escaño es adicional a los 72 senadores y no se descuenta del total del partido.
|
||||
</p>
|
||||
<select id="presidencia-senado-nacional" value={presidenciaSenadoId} onChange={e => setPresidenciaSenadoId(e.target.value)} style={{ width: '100%', padding: '8px' }}>
|
||||
<option value="">-- No Asignado --</option>
|
||||
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{`(${a.id}) ${a.nombre}`}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} style={{ marginTop: '1.5rem' }}>
|
||||
Guardar Configuración
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,89 @@
|
||||
// src/components/DashboardPage.tsx
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { AgrupacionesManager } from './AgrupacionesManager';
|
||||
import { OrdenDiputadosManager } from './OrdenDiputadosManager';
|
||||
import { OrdenSenadoresManager } from './OrdenSenadoresManager';
|
||||
import { ConfiguracionGeneral } from './ConfiguracionGeneral';
|
||||
import { BancasManager } from './BancasManager';
|
||||
//import { OrdenDiputadosManager } from './OrdenDiputadosManager';
|
||||
//import { OrdenSenadoresManager } from './OrdenSenadoresManager';
|
||||
//import { ConfiguracionGeneral } from './ConfiguracionGeneral';
|
||||
import { LogoOverridesManager } from './LogoOverridesManager';
|
||||
import { CandidatoOverridesManager } from './CandidatoOverridesManager';
|
||||
import { WorkerManager } from './WorkerManager';
|
||||
import { ConfiguracionNacional } from './ConfiguracionNacional';
|
||||
import { BancasPreviasManager } from './BancasPreviasManager';
|
||||
import { OrdenDiputadosNacionalesManager } from './OrdenDiputadosNacionalesManager';
|
||||
import { OrdenSenadoresNacionalesManager } from './OrdenSenadoresNacionalesManager';
|
||||
//import { BancasProvincialesManager } from './BancasProvincialesManager';
|
||||
//import { BancasNacionalesManager } from './BancasNacionalesManager';
|
||||
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const { logout } = useAuth();
|
||||
|
||||
const sectionStyle = {
|
||||
border: '1px solid #dee2e6',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '2rem',
|
||||
backgroundColor: '#f8f9fa'
|
||||
};
|
||||
|
||||
const sectionTitleStyle = {
|
||||
marginTop: 0,
|
||||
borderBottom: '2px solid #007bff',
|
||||
paddingBottom: '0.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
color: '#007bff'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '1rem 2rem' }}>
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '2px solid #eee', paddingBottom: '1rem' }}>
|
||||
<header style={{ /* ... */ }}>
|
||||
<h1>Panel de Administración Electoral</h1>
|
||||
<button onClick={logout}>Cerrar Sesión</button>
|
||||
</header>
|
||||
|
||||
<main style={{ marginTop: '2rem' }}>
|
||||
<AgrupacionesManager />
|
||||
<div style={{ flex: '1 1 800px' }}>
|
||||
<LogoOverridesManager />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 800px' }}>
|
||||
<CandidatoOverridesManager />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenDiputadosManager />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenSenadoresManager />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>Configuración Global</h2>
|
||||
<AgrupacionesManager />
|
||||
<LogoOverridesManager />
|
||||
<CandidatoOverridesManager />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>Gestión de Elecciones Nacionales</h2>
|
||||
<ConfiguracionNacional />
|
||||
<BancasPreviasManager />
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenDiputadosNacionalesManager />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenSenadoresNacionalesManager />
|
||||
</div>
|
||||
</div>
|
||||
{/* <BancasNacionalesManager /> */}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>Gestión de Elecciones Provinciales</h2>
|
||||
<ConfiguracionGeneral />
|
||||
<BancasProvincialesManager />
|
||||
<div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap', marginTop: '2rem' }}>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenDiputadosManager />
|
||||
</div>
|
||||
<div style={{ flex: '1 1 400px' }}>
|
||||
<OrdenSenadoresManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>*/}
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2 style={sectionTitleStyle}>Gestión de Workers y Sistema</h2>
|
||||
<WorkerManager />
|
||||
</div>
|
||||
<ConfiguracionGeneral />
|
||||
<BancasManager />
|
||||
<hr style={{ margin: '2rem 0' }}/>
|
||||
<WorkerManager />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
72
Elecciones-Web/frontend-admin/src/components/FormStyles.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* src/components/FormStyles.css */
|
||||
|
||||
.add-entity-form-container {
|
||||
border-top: 2px solid #007bff;
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.add-entity-form-container h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-entity-form {
|
||||
display: grid;
|
||||
/* Usamos grid para un control preciso de las columnas */
|
||||
grid-template-columns: 3fr 2fr 0.5fr auto;
|
||||
gap: 1rem;
|
||||
align-items: flex-end; /* Alinea los elementos en la parte inferior de la celda */
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #555;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-field input[type="text"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-field input[type="color"] {
|
||||
height: 38px; /* Misma altura que los inputs de texto */
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 4px; /* Padding interno para el color */
|
||||
}
|
||||
|
||||
.add-entity-form button {
|
||||
padding: 8px 16px;
|
||||
height: 38px; /* Misma altura que los inputs */
|
||||
border: none;
|
||||
background-color: #28a745; /* Un color verde para la acción de "crear" */
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.add-entity-form button:hover:not(:disabled) {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.add-entity-form button:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -2,83 +2,119 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService';
|
||||
import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria } from '../types';
|
||||
import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getLogos, updateLogos } from '../services/apiService';
|
||||
import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria, ProvinciaSimple } from '../types';
|
||||
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
|
||||
|
||||
// --- AÑADIMOS LAS CATEGORÍAS PARA EL SELECTOR ---
|
||||
const CATEGORIAS_OPTIONS = [
|
||||
{ value: 5, label: 'Senadores' },
|
||||
{ value: 6, label: 'Diputados' },
|
||||
{ value: 7, label: 'Concejales' }
|
||||
const ELECCION_OPTIONS = [
|
||||
{ value: 0, label: 'General (Todas las elecciones)' },
|
||||
{ value: 2, label: 'Elecciones Nacionales' },
|
||||
{ value: 1, label: 'Elecciones Provinciales' }
|
||||
];
|
||||
|
||||
const AMBITO_LEVEL_OPTIONS = [
|
||||
{ value: 'general', label: 'General (Toda la elección)' },
|
||||
{ value: 'provincia', label: 'Por Provincia' },
|
||||
{ value: 'municipio', label: 'Por Municipio' }
|
||||
];
|
||||
|
||||
export const LogoOverridesManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
|
||||
const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({ queryKey: ['logos'], queryFn: getLogos });
|
||||
|
||||
// --- NUEVO ESTADO PARA LA CATEGORÍA ---
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
|
||||
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null);
|
||||
const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedAgrupacion, setSelectedAgrupacion] = useState<{ value: string; label: string } | null>(null);
|
||||
const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
|
||||
const municipioOptions = useMemo(() =>
|
||||
[{ value: 'general', label: 'General (Todas las secciones)' }, ...municipios.map(m => ({ value: m.id, label: m.nombre }))]
|
||||
, [municipios]);
|
||||
const agrupacionOptions = useMemo(() => agrupaciones.map(a => ({ value: a.id, label: a.nombre })), [agrupaciones]);
|
||||
const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin });
|
||||
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
|
||||
|
||||
const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({
|
||||
queryKey: ['allLogos'],
|
||||
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
const categoriaOptions = useMemo(() => {
|
||||
if (selectedEleccion.value === 2) return CATEGORIAS_NACIONALES_OPTIONS;
|
||||
if (selectedEleccion.value === 1) return CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
return [...CATEGORIAS_NACIONALES_OPTIONS, ...CATEGORIAS_PROVINCIALES_OPTIONS];
|
||||
}, [selectedEleccion]);
|
||||
|
||||
const getAmbitoId = () => {
|
||||
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
|
||||
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentLogo = useMemo(() => {
|
||||
// La búsqueda ahora depende de los 3 selectores
|
||||
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return '';
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
const ambitoId = getAmbitoId();
|
||||
|
||||
return logos.find(l =>
|
||||
l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&
|
||||
l.agrupacionPoliticaId === selectedAgrupacion.value &&
|
||||
l.eleccionId === selectedEleccion.value &&
|
||||
l.ambitoGeograficoId === ambitoId &&
|
||||
l.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
l.categoriaId === selectedCategoria.value
|
||||
)?.logoUrl || '';
|
||||
}, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
}, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]);
|
||||
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
|
||||
if (!selectedAgrupacion || !selectedCategoria) {
|
||||
alert("Por favor, seleccione una agrupación y una categoría.");
|
||||
return;
|
||||
}
|
||||
const newLogoEntry: LogoAgrupacionCategoria = {
|
||||
id: 0,
|
||||
agrupacionPoliticaId: selectedAgrupacion.value,
|
||||
eleccionId: selectedEleccion.value,
|
||||
agrupacionPoliticaId: selectedAgrupacion.id,
|
||||
categoriaId: selectedCategoria.value,
|
||||
ambitoGeograficoId: parseInt(selectedMunicipio.value),
|
||||
logoUrl: logoUrl || null
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
logoUrl: logoUrl.trim() || null
|
||||
};
|
||||
try {
|
||||
await updateLogos([newLogoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['logos'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allLogos'] });
|
||||
alert('Override de logo guardado.');
|
||||
} catch { alert('Error al guardar.'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Overrides de Logos por Municipio y Categoría</h3>
|
||||
<p>Configure una imagen específica para un partido en un municipio y categoría determinados.</p>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end' }}>
|
||||
<h3>Overrides de Logos</h3>
|
||||
<p>Configure una imagen específica para un partido en un contexto determinado.</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', alignItems: 'flex-end' }}>
|
||||
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => { setSelectedEleccion(opt!); setSelectedCategoria(null); }} />
|
||||
<Select options={categoriaOptions} value={selectedCategoria} onChange={setSelectedCategoria} placeholder="Seleccione Categoría..." />
|
||||
<Select
|
||||
options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))}
|
||||
getOptionValue={opt => opt.id}
|
||||
getOptionLabel={opt => `(${opt.id}) ${opt.nombre}`}
|
||||
value={selectedAgrupacion}
|
||||
onChange={setSelectedAgrupacion}
|
||||
placeholder="Seleccione Agrupación..."
|
||||
/>
|
||||
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
|
||||
|
||||
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
|
||||
) : <div />}
|
||||
|
||||
{selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
|
||||
) : <div />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Categoría</label>
|
||||
<Select options={CATEGORIAS_OPTIONS} value={selectedCategoria} onChange={setSelectedCategoria} isClearable placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Municipio</label>
|
||||
<Select options={municipioOptions} value={selectedMunicipio} onChange={setSelectedMunicipio} isClearable placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>Agrupación</label>
|
||||
<Select options={agrupacionOptions} value={selectedAgrupacion} onChange={setSelectedAgrupacion} isClearable placeholder="Seleccione..."/>
|
||||
</div>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label>URL del Logo Específico</label>
|
||||
<input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria} />
|
||||
<input type="text" value={logoUrl} onChange={e => setLogoUrl(e.target.value)} style={{ width: '100%' }} disabled={!selectedAgrupacion || !selectedCategoria} />
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={!selectedMunicipio || !selectedAgrupacion || !selectedCategoria}>Guardar</button>
|
||||
<button onClick={handleSave} disabled={!selectedAgrupacion || !selectedCategoria}>Guardar</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos
|
||||
const updateOrdenDiputadosApi = async (ids: string[]) => {
|
||||
const token = localStorage.getItem('admin-jwt-token');
|
||||
const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-diputados', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(ids)
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(ids)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save Diputados order");
|
||||
@@ -38,77 +38,77 @@ const updateOrdenDiputadosApi = async (ids: string[]) => {
|
||||
};
|
||||
|
||||
export const OrdenDiputadosManager = () => {
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSortAgrupaciones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAgrupaciones();
|
||||
// Ordenar por el orden de Diputados. Los nulos van al final.
|
||||
data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999));
|
||||
setAgrupaciones(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agrupaciones for Diputados:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndSortAgrupaciones();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupaciones((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
useEffect(() => {
|
||||
const fetchAndSortAgrupaciones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAgrupaciones();
|
||||
// Ordenar por el orden de Diputados. Los nulos van al final.
|
||||
data.sort((a, b) => (a.ordenDiputados || 999) - (b.ordenDiputados || 999));
|
||||
setAgrupaciones(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agrupaciones for Diputados:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndSortAgrupaciones();
|
||||
}, []);
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupaciones.map(a => a.id);
|
||||
try {
|
||||
await updateOrdenDiputadosApi(idsOrdenados);
|
||||
alert('Orden de Diputados guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Diputados.');
|
||||
}
|
||||
};
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupaciones((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>Cargando orden de Diputados...</p>;
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupaciones.map(a => a.id);
|
||||
try {
|
||||
await updateOrdenDiputadosApi(idsOrdenados);
|
||||
alert('Orden de Diputados guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Diputados.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Diputados)</h3>
|
||||
<p>Arrastre para reordenar.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={agrupaciones.map(a => a.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{agrupacion.nombreCorto || agrupacion.nombre}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button>
|
||||
</div>
|
||||
);
|
||||
if (loading) return <p>Cargando orden de Diputados...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Diputados)</h3>
|
||||
<p>Arrastre para reordenar.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={agrupaciones.map(a => a.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Diputados</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
// src/components/OrdenDiputadosNacionalesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import { SortableItem } from './SortableItem';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const OrdenDiputadosNacionalesManager = () => {
|
||||
// Estado para la lista que el usuario puede ordenar
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
// Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.)
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
// Query 2: Obtener los datos de composición para saber qué partidos tienen bancas
|
||||
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
|
||||
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
// Este efecto se ejecuta cuando los datos de las queries estén disponibles
|
||||
useEffect(() => {
|
||||
// No hacemos nada hasta que ambas queries hayan cargado sus datos
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de diputado
|
||||
const partidosConBancasIds = new Set(
|
||||
composicionData.diputados.partidos
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map(p => p.id)
|
||||
);
|
||||
|
||||
// Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
// Ordenamos la lista filtrada según el orden guardado en la BD
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999));
|
||||
|
||||
// Actualizamos el estado que se renderiza y que el usuario puede ordenar
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
}, [todasAgrupaciones, composicionData]); // Dependencias: se re-ejecuta si los datos cambian
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupacionesOrdenadas((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
|
||||
try {
|
||||
await updateOrden('diputados-nacionales', idsOrdenados);
|
||||
alert('Orden de Diputados Nacionales guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Diputados Nacionales.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Diputados Nacionales)</h3>
|
||||
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupacionesOrdenadas.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,12 +25,12 @@ import './AgrupacionesManager.css'; // Reutilizamos los estilos
|
||||
const updateOrdenSenadoresApi = async (ids: string[]) => {
|
||||
const token = localStorage.getItem('admin-jwt-token');
|
||||
const response = await fetch('http://localhost:5217/api/admin/agrupaciones/orden-senadores', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(ids)
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(ids)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to save Senadores order");
|
||||
@@ -38,77 +38,77 @@ const updateOrdenSenadoresApi = async (ids: string[]) => {
|
||||
};
|
||||
|
||||
export const OrdenSenadoresManager = () => {
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAndSortAgrupaciones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAgrupaciones();
|
||||
// Ordenar por el orden de Senadores. Los nulos van al final.
|
||||
data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999));
|
||||
setAgrupaciones(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agrupaciones for Senadores:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndSortAgrupaciones();
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupaciones((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
useEffect(() => {
|
||||
const fetchAndSortAgrupaciones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAgrupaciones();
|
||||
// Ordenar por el orden de Senadores. Los nulos van al final.
|
||||
data.sort((a, b) => (a.ordenSenadores || 999) - (b.ordenSenadores || 999));
|
||||
setAgrupaciones(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agrupaciones for Senadores:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAndSortAgrupaciones();
|
||||
}, []);
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupaciones.map(a => a.id);
|
||||
try {
|
||||
await updateOrdenSenadoresApi(idsOrdenados);
|
||||
alert('Orden de Senadores guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Senadores.');
|
||||
}
|
||||
};
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupaciones((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <p>Cargando orden de Senadores...</p>;
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupaciones.map(a => a.id);
|
||||
try {
|
||||
await updateOrdenSenadoresApi(idsOrdenados);
|
||||
alert('Orden de Senadores guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Senadores.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Senado)</h3>
|
||||
<p>Arrastre para reordenar.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={agrupaciones.map(a => a.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{agrupacion.nombreCorto || agrupacion.nombre}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button>
|
||||
</div>
|
||||
);
|
||||
if (loading) return <p>Cargando orden de Senadores...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Senado)</h3>
|
||||
<p>Arrastre para reordenar.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={agrupaciones.map(a => a.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden Senado</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// src/components/OrdenSenadoresNacionalesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import { SortableItem } from './SortableItem';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const OrdenSenadoresNacionalesManager = () => {
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
|
||||
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de senador
|
||||
const partidosConBancasIds = new Set(
|
||||
composicionData.senadores.partidos
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map(p => p.id)
|
||||
);
|
||||
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999));
|
||||
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
}, [todasAgrupaciones, composicionData]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupacionesOrdenadas((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
|
||||
try {
|
||||
await updateOrden('senadores-nacionales', idsOrdenados);
|
||||
alert('Orden de Senadores Nacionales guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Senadores Nacionales.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Senado de la Nación)</h3>
|
||||
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupacionesOrdenadas.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{`(${agrupacion.id}) ${agrupacion.nombreCorto || agrupacion.nombre}`}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
Elecciones-Web/frontend-admin/src/constants/categorias.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// src/constants/categorias.ts
|
||||
|
||||
// Opciones para los selectores en el panel de administración
|
||||
export const CATEGORIAS_ADMIN_OPTIONS = [
|
||||
// Nacionales
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
// Provinciales
|
||||
{ value: 5, label: 'Senadores Provinciales' },
|
||||
{ value: 6, label: 'Diputados Provinciales' },
|
||||
{ value: 7, label: 'Concejales' },
|
||||
];
|
||||
|
||||
export const CATEGORIAS_NACIONALES_OPTIONS = [
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
];
|
||||
|
||||
export const CATEGORIAS_PROVINCIALES_OPTIONS = [
|
||||
{ value: 5, label: 'Senadores Provinciales' },
|
||||
{ value: 6, label: 'Diputados Provinciales' },
|
||||
{ value: 7, label: 'Concejales' },
|
||||
];
|
||||
@@ -1,11 +1,14 @@
|
||||
// src/services/apiService.ts
|
||||
import axios from 'axios';
|
||||
import { triggerLogout } from '../context/authUtils';
|
||||
import type { CandidatoOverride, AgrupacionPolitica, UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria, MunicipioSimple } from '../types';
|
||||
import type {
|
||||
CandidatoOverride, AgrupacionPolitica,
|
||||
UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria,
|
||||
MunicipioSimple, BancaPrevia, ProvinciaSimple
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* URL base para las llamadas a la API.
|
||||
* Se usa para construir las URLs más específicas.
|
||||
*/
|
||||
const API_URL_BASE = import.meta.env.DEV
|
||||
? 'http://localhost:5217/api'
|
||||
@@ -21,13 +24,19 @@ export const AUTH_API_URL = `${API_URL_BASE}/auth`;
|
||||
*/
|
||||
export const ADMIN_API_URL = `${API_URL_BASE}/admin`;
|
||||
|
||||
// Cliente de API para endpoints de administración (requiere token)
|
||||
const adminApiClient = axios.create({
|
||||
baseURL: ADMIN_API_URL,
|
||||
});
|
||||
|
||||
// --- INTERCEPTORES ---
|
||||
// Cliente de API para endpoints públicos (no envía token)
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Interceptor de Peticiones: Añade el token JWT a cada llamada
|
||||
|
||||
// --- INTERCEPTORES (Solo para el cliente de admin) ---
|
||||
adminApiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('admin-jwt-token');
|
||||
@@ -39,7 +48,6 @@ adminApiClient.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Interceptor de Respuestas: Maneja la expiración del token (error 401)
|
||||
adminApiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
@@ -51,6 +59,32 @@ adminApiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// --- INTERFACES PARA COMPOSICIÓN NACIONAL (NECESARIAS PARA EL NUEVO MÉTODO) ---
|
||||
export interface PartidoComposicionNacional {
|
||||
id: string;
|
||||
nombre: string;
|
||||
nombreCorto: string | null;
|
||||
color: string | null;
|
||||
bancasFijos: number;
|
||||
bancasGanadas: number;
|
||||
bancasTotales: number;
|
||||
ordenDiputadosNacionales: number | null;
|
||||
ordenSenadoresNacionales: number | null;
|
||||
}
|
||||
export interface CamaraComposicionNacional {
|
||||
camaraNombre: string;
|
||||
totalBancas: number;
|
||||
bancasEnJuego: number;
|
||||
partidos: PartidoComposicionNacional[];
|
||||
presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null;
|
||||
}
|
||||
export interface ComposicionNacionalData {
|
||||
diputados: CamaraComposicionNacional;
|
||||
senadores: CamaraComposicionNacional;
|
||||
}
|
||||
|
||||
|
||||
// --- SERVICIOS DE API ---
|
||||
|
||||
// 1. Autenticación
|
||||
@@ -66,7 +100,7 @@ export const loginUser = async (credentials: LoginCredentials): Promise<string |
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Agrupaciones Políticas
|
||||
// 2. Agrupaciones
|
||||
export const getAgrupaciones = async (): Promise<AgrupacionPolitica[]> => {
|
||||
const response = await adminApiClient.get('/agrupaciones');
|
||||
return response.data;
|
||||
@@ -77,14 +111,14 @@ export const updateAgrupacion = async (id: string, data: UpdateAgrupacionData):
|
||||
};
|
||||
|
||||
// 3. Ordenamiento de Agrupaciones
|
||||
export const updateOrden = async (camara: 'diputados' | 'senadores', ids: string[]): Promise<void> => {
|
||||
export const updateOrden = async (camara: 'diputados' | 'senadores' | 'diputados-nacionales' | 'senadores-nacionales', ids: string[]): Promise<void> => {
|
||||
await adminApiClient.put(`/agrupaciones/orden-${camara}`, ids);
|
||||
};
|
||||
|
||||
// 4. Gestión de Bancas y Ocupantes
|
||||
export const getBancadas = async (camara: 'diputados' | 'senadores'): Promise<Bancada[]> => {
|
||||
const camaraId = camara === 'diputados' ? 0 : 1;
|
||||
const response = await adminApiClient.get(`/bancadas/${camaraId}`);
|
||||
// 4. Gestión de Bancas
|
||||
export const getBancadas = async (camara: 'diputados' | 'senadores', eleccionId: number): Promise<Bancada[]> => {
|
||||
const camaraId = (camara === 'diputados') ? 0 : 1;
|
||||
const response = await adminApiClient.get(`/bancadas/${camaraId}?eleccionId=${eleccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -111,38 +145,64 @@ export const updateConfiguracion = async (data: Record<string, string>): Promise
|
||||
await adminApiClient.put('/configuracion', data);
|
||||
};
|
||||
|
||||
export const getLogos = async (): Promise<LogoAgrupacionCategoria[]> => {
|
||||
const response = await adminApiClient.get('/logos');
|
||||
// 6. Logos y Candidatos
|
||||
export const getLogos = async (eleccionId: number): Promise<LogoAgrupacionCategoria[]> => {
|
||||
const response = await adminApiClient.get(`/logos?eleccionId=${eleccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateLogos = async (data: LogoAgrupacionCategoria[]): Promise<void> => {
|
||||
await adminApiClient.put('/logos', data);
|
||||
};
|
||||
|
||||
export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => {
|
||||
// Ahora usa adminApiClient, que apunta a /api/admin/
|
||||
// La URL final será /api/admin/catalogos/municipios
|
||||
const response = await adminApiClient.get('/catalogos/municipios');
|
||||
export const getCandidatos = async (eleccionId: number): Promise<CandidatoOverride[]> => {
|
||||
const response = await adminApiClient.get(`/candidatos?eleccionId=${eleccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 6. Overrides de Candidatos
|
||||
export const getCandidatos = async (): Promise<CandidatoOverride[]> => {
|
||||
const response = await adminApiClient.get('/candidatos');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateCandidatos = async (data: CandidatoOverride[]): Promise<void> => {
|
||||
await adminApiClient.put('/candidatos', data);
|
||||
};
|
||||
|
||||
// 7. Gestión de Logging
|
||||
export interface UpdateLoggingLevelData {
|
||||
level: string;
|
||||
}
|
||||
// 7. Catálogos
|
||||
export const getMunicipiosForAdmin = async (): Promise<MunicipioSimple[]> => {
|
||||
const response = await adminApiClient.get('/catalogos/municipios');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 8. Logging
|
||||
export interface UpdateLoggingLevelData { level: string; }
|
||||
export const updateLoggingLevel = async (data: UpdateLoggingLevelData): Promise<void> => {
|
||||
// Este endpoint es específico, no es parte de la configuración general
|
||||
await adminApiClient.put(`/logging-level`, data);
|
||||
};
|
||||
|
||||
// 9. Bancas Previas
|
||||
export const getBancasPrevias = async (eleccionId: number): Promise<BancaPrevia[]> => {
|
||||
const response = await adminApiClient.get(`/bancas-previas/${eleccionId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const updateBancasPrevias = async (eleccionId: number, data: BancaPrevia[]): Promise<void> => {
|
||||
await adminApiClient.put(`/bancas-previas/${eleccionId}`, data);
|
||||
};
|
||||
|
||||
// 10. Obtener Composición Nacional (Endpoint Público)
|
||||
export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => {
|
||||
// Este es un endpoint público, por lo que usamos el cliente sin token de admin.
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// Obtenemos las provincias para el selector de ámbito
|
||||
export const getProvinciasForAdmin = async (): Promise<ProvinciaSimple[]> => {
|
||||
const response = await adminApiClient.get('/catalogos/provincias');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export interface CreateAgrupacionData {
|
||||
nombre: string;
|
||||
nombreCorto: string | null;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
// Servicio para crear una nueva agrupación
|
||||
export const createAgrupacion = async (data: CreateAgrupacionData): Promise<AgrupacionPolitica> => {
|
||||
const response = await adminApiClient.post('/agrupaciones', data);
|
||||
return response.data;
|
||||
};
|
||||
@@ -8,6 +8,8 @@ export interface AgrupacionPolitica {
|
||||
color: string | null;
|
||||
ordenDiputados: number | null;
|
||||
ordenSenadores: number | null;
|
||||
ordenDiputadosNacionales: number | null;
|
||||
ordenSenadoresNacionales: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgrupacionData {
|
||||
@@ -30,9 +32,9 @@ export interface OcupanteBanca {
|
||||
periodo: string | null;
|
||||
}
|
||||
|
||||
// Nueva interfaz para la Bancada
|
||||
export interface Bancada {
|
||||
id: number;
|
||||
eleccionId: number; // Clave para diferenciar provinciales de nacionales
|
||||
camara: TipoCamaraValue;
|
||||
numeroBanca: number;
|
||||
agrupacionPoliticaId: string | null;
|
||||
@@ -40,18 +42,33 @@ export interface Bancada {
|
||||
ocupante: OcupanteBanca | null;
|
||||
}
|
||||
|
||||
// Nueva interfaz para Bancas Previas
|
||||
export interface BancaPrevia {
|
||||
id: number;
|
||||
eleccionId: number;
|
||||
camara: TipoCamaraValue;
|
||||
agrupacionPoliticaId: string;
|
||||
agrupacionPolitica?: AgrupacionPolitica; // Opcional para la UI
|
||||
cantidad: number;
|
||||
}
|
||||
|
||||
|
||||
export interface LogoAgrupacionCategoria {
|
||||
id: number;
|
||||
eleccionId: number; // Clave para diferenciar
|
||||
agrupacionPoliticaId: string;
|
||||
categoriaId: number;
|
||||
categoriaId: number | null;
|
||||
logoUrl: string | null;
|
||||
ambitoGeograficoId: number | null;
|
||||
}
|
||||
|
||||
export interface MunicipioSimple { id: string; nombre: string; }
|
||||
|
||||
export interface ProvinciaSimple { id: string; nombre: string; }
|
||||
|
||||
export interface CandidatoOverride {
|
||||
id: number;
|
||||
eleccionId: number; // Clave para diferenciar
|
||||
agrupacionPoliticaId: string;
|
||||
categoriaId: number;
|
||||
ambitoGeograficoId: number | null;
|
||||
|
||||
573
Elecciones-Web/frontend/package-lock.json
generated
@@ -18,13 +18,20 @@
|
||||
"axios": "^1.11.0",
|
||||
"d3-geo": "^3.1.1",
|
||||
"d3-shape": "^3.2.0",
|
||||
"highcharts": "^12.4.0",
|
||||
"highcharts-react-official": "^3.2.2",
|
||||
"react": "^19.1.1",
|
||||
"react-circular-progressbar": "^2.2.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-pdf": "^10.1.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-simple-maps": "github:ozimmortal/react-simple-maps#feat/react-19-support",
|
||||
"react-tooltip": "^5.29.1",
|
||||
"topojson-client": "^3.1.0"
|
||||
"swiper": "^12.0.2",
|
||||
"topojson-client": "^3.1.0",
|
||||
"vite-plugin-svgr": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
|
||||
154
Elecciones-Web/frontend/public/bootstrap.js
vendored
@@ -4,9 +4,16 @@
|
||||
// El dominio donde se alojan los widgets
|
||||
const WIDGETS_HOST = 'https://elecciones2025.eldia.com';
|
||||
|
||||
// Función para cargar dinámicamente un script
|
||||
// Estado interno para evitar recargas y re-fetch innecesarios
|
||||
const __state = {
|
||||
assetsLoaded: false,
|
||||
manifest: null,
|
||||
};
|
||||
|
||||
// Función para cargar dinámicamente un script (evita duplicados)
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ([...document.scripts].some(s => s.src === src)) return resolve();
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = src;
|
||||
@@ -16,73 +23,116 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Función para cargar dinámicamente una hoja de estilos
|
||||
// Función para cargar dinámicamente una hoja de estilos (evita duplicados)
|
||||
function loadCSS(href) {
|
||||
if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(l => l.href === href)) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Función principal
|
||||
// Carga (una sola vez) JS/CSS definidos por el manifest
|
||||
async function ensureAssetsFromManifest() {
|
||||
if (__state.assetsLoaded) return;
|
||||
|
||||
// 1) Obtener el manifest.json (cache: no-store por si hay deploys frecuentes)
|
||||
if (!__state.manifest) {
|
||||
const response = await fetch(`${WIDGETS_HOST}/manifest.json`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('No se pudo cargar el manifest de los widgets.');
|
||||
__state.manifest = await response.json();
|
||||
}
|
||||
|
||||
// 2) Encontrar el entry principal (isEntry=true)
|
||||
const entryKey = Object.keys(__state.manifest).find(key => __state.manifest[key].isEntry);
|
||||
if (!entryKey) throw new Error('No se encontró el punto de entrada en el manifest.');
|
||||
|
||||
const entry = __state.manifest[entryKey];
|
||||
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
|
||||
|
||||
// 3) Cargar el CSS si existe (una sola vez)
|
||||
if (entry.css && entry.css.length > 0) {
|
||||
entry.css.forEach(cssFile => loadCSS(`${WIDGETS_HOST}/${cssFile}`));
|
||||
}
|
||||
|
||||
// 4) Cargar el JS principal (una sola vez)
|
||||
await loadScript(jsUrl);
|
||||
|
||||
__state.assetsLoaded = true;
|
||||
}
|
||||
|
||||
// Render: busca contenedores y llama a la API global del widget
|
||||
function renderWidgetsOnPage() {
|
||||
if (!(window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function')) {
|
||||
// La librería aún no expuso la API (puede ocurrir en primeros ms tras cargar)
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
|
||||
if (widgetContainers.length === 0) {
|
||||
// En algunas rutas no habrá widgets: no es error.
|
||||
return;
|
||||
}
|
||||
|
||||
widgetContainers.forEach(container => {
|
||||
window.EleccionesWidgets.render(container, container.dataset);
|
||||
});
|
||||
}
|
||||
|
||||
// Función principal (re-usable) para inicializar y renderizar
|
||||
async function initWidgets() {
|
||||
try {
|
||||
// 1. Obtener el manifest.json para saber los nombres de archivo actuales
|
||||
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error('No se pudo cargar el manifest de los widgets.');
|
||||
}
|
||||
const manifest = await response.json();
|
||||
|
||||
// 2. Encontrar el punto de entrada principal (nuestro main.tsx)
|
||||
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
|
||||
if (!entryKey) {
|
||||
throw new Error('No se encontró el punto de entrada en el manifest.');
|
||||
}
|
||||
|
||||
const entry = manifest[entryKey];
|
||||
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
|
||||
|
||||
// 3. Cargar el CSS si existe
|
||||
if (entry.css && entry.css.length > 0) {
|
||||
entry.css.forEach(cssFile => {
|
||||
const cssUrl = `${WIDGETS_HOST}/${cssFile}`;
|
||||
loadCSS(cssUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Cargar el JS principal y esperar a que esté listo
|
||||
await loadScript(jsUrl);
|
||||
|
||||
|
||||
// 5. Una vez cargado, llamar a la función de renderizado.
|
||||
if (window.EleccionesWidgets && typeof window.EleccionesWidgets.render === 'function') {
|
||||
console.log('Bootstrap: La función render existe. Renderizando todos los widgets encontrados...');
|
||||
|
||||
const widgetContainers = document.querySelectorAll('[data-elecciones-widget]');
|
||||
|
||||
if (widgetContainers.length === 0) {
|
||||
console.warn('Bootstrap: No se encontraron contenedores de widget en la página.');
|
||||
}
|
||||
|
||||
widgetContainers.forEach(container => {
|
||||
// 'dataset' es un objeto que contiene todos los atributos data-*
|
||||
window.EleccionesWidgets.render(container, container.dataset);
|
||||
});
|
||||
} else {
|
||||
console.error('Bootstrap: ERROR CRÍTICO - La función render() NO SE ENCONTRÓ en window.EleccionesWidgets.');
|
||||
console.log('Bootstrap: Contenido de window.EleccionesWidgets:', window.EleccionesWidgets);
|
||||
}
|
||||
|
||||
await ensureAssetsFromManifest();
|
||||
renderWidgetsOnPage();
|
||||
} catch (error) {
|
||||
console.error('Error al inicializar los widgets de elecciones:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') { // Aún cargando
|
||||
// Exponer para invocación manual (por ejemplo, en hooks del router)
|
||||
window.__eleccionesInit = initWidgets;
|
||||
|
||||
// Primer render en carga inicial
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWidgets);
|
||||
} else { // Ya cargado
|
||||
} else {
|
||||
initWidgets();
|
||||
}
|
||||
|
||||
// --- Reinvocar en cada navegación de SPA ---
|
||||
function dispatchLocationChange() {
|
||||
window.dispatchEvent(new Event('locationchange'));
|
||||
}
|
||||
|
||||
['pushState', 'replaceState'].forEach(method => {
|
||||
const orig = history[method];
|
||||
history[method] = function () {
|
||||
const ret = orig.apply(this, arguments);
|
||||
dispatchLocationChange();
|
||||
return ret;
|
||||
};
|
||||
});
|
||||
window.addEventListener('popstate', dispatchLocationChange);
|
||||
|
||||
let navDebounce = null;
|
||||
window.addEventListener('locationchange', () => {
|
||||
clearTimeout(navDebounce);
|
||||
navDebounce = setTimeout(() => {
|
||||
initWidgets();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
// --- (Opcional) Re-render si aparecen contenedores luego del montaje de la vista ---
|
||||
const mo = new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
if (m.type === 'childList') {
|
||||
const added = [...m.addedNodes].some(n =>
|
||||
n.nodeType === 1 &&
|
||||
(n.matches?.('[data-elecciones-widget]') || n.querySelector?.('[data-elecciones-widget]'))
|
||||
);
|
||||
if (added) { renderWidgetsOnPage(); break; }
|
||||
}
|
||||
}
|
||||
});
|
||||
mo.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
23
Elecciones-Web/frontend/public/maps/provincias-svg/caba.svg
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="1.3493201mm"
|
||||
height="1.6933239mm"
|
||||
viewBox="0 0 1.3493201 1.6933238"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-103.9813,-147.63758)">
|
||||
<path
|
||||
d="m 105.33062,148.53708 -0.0264,0.0794 -0.1323,0.13229 -0.1852,0.0265 -0.15875,0.15875 -0.21167,0.39687 -0.52917,-0.44979 -0.10583,-0.37042 0.13229,-0.58208 0.34396,-0.29104 0.13229,0.0794 0.10583,0.0529 0.10584,0.0794 0.18521,0.13229 0.0794,0.0794 0.10583,0.15875 0.0794,0.15875 z"
|
||||
id="ARC"
|
||||
name="Ciudad de Buenos Aires"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 821 B |
|
After Width: | Height: | Size: 7.7 KiB |
23
Elecciones-Web/frontend/public/maps/provincias-svg/chaco.svg
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="22.092972mm"
|
||||
height="36.143562mm"
|
||||
viewBox="0 0 22.092972 36.143562"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-93.662753,-130.4396)">
|
||||
<path
|
||||
d="m 95.646872,134.9375 1.190625,-0.3175 0.264583,-0.15875 0.47625,-0.50271 0.582083,-0.66146 0.05292,-0.10583 0.02646,-0.13229 -0.15875,-1.45521 0.05292,-0.21167 0.370417,-0.13229 1.5875,-0.39687 1.03188,-0.29105 0.60854,-0.13229 h 0.21167 l 1.08479,0.26459 0.0529,0.0264 v 0.0265 l 0.0265,0.0794 -0.0265,0.15875 v 0.0794 l 0.0265,0.0529 0.0794,0.0529 0.39687,0.23812 0.0265,0.0265 0.0529,0.0794 0.0529,0.0529 0.0265,0.0265 0.34396,0.0529 0.0529,0.0265 0.0265,0.0265 0.0794,0.0529 0.0529,0.0529 0.0529,0.0265 h 0.0529 l 0.3175,0.0265 h 0.10583 l 0.635,-0.10583 0.0794,0.0265 0.39687,0.10583 0.0529,0.0265 0.21167,-0.0529 h 0.0529 l 0.0265,0.0265 0.0265,0.0265 v 0.0265 0.0265 0.0265 l -0.0265,0.18521 v 0.0265 l 0.0265,0.0529 h 0.0529 l 0.1852,0.0265 0.0529,0.0264 0.0529,0.0265 v 0.0265 0.0529 0.10583 l 0.0265,0.0265 0.0264,0.0529 0.1323,0.0264 h 3.12208 l 1.85208,-0.0264 0.89959,0.0529 0.26458,0.15875 0.84667,2.2225 -0.26459,1.66688 0.0265,0.15875 0.0265,0.10583 1.21708,1.34938 0.26459,0.37041 -0.0529,0.37042 -1.40229,5.26521 -0.10584,0.21166 -0.23812,0.21167 -0.21167,0.21167 -0.0794,0.0794 v 0.0794 0.0265 l 0.0794,0.15875 0.0265,0.0265 v 0.0265 0.0529 0.0794 l -0.0265,0.29105 v 0.0794 l 0.0265,0.39688 0.0265,0.0264 v 0.0265 0.0265 l 0.0794,0.13229 0.0529,0.0529 0.0265,0.0265 v 0.0529 0.0529 l -0.0794,0.18521 -0.0529,0.10583 -0.0265,0.10584 -0.0265,0.15875 v 0.52916 l 0.13229,0.15875 0.13229,0.10584 0.15875,0.21166 0.0529,0.0265 0.15875,0.0794 0.10583,0.0794 0.0265,0.0265 0.0265,0.0529 0.15875,0.55562 0.0265,0.10583 0.13229,0.21167 0.39688,0.29104 0.0529,0.0794 0.0794,0.0794 0.0265,0.0529 0.0264,0.13229 0.0529,0.21167 v 0.21166 l 0.0265,0.0265 0.0265,0.0529 0.0265,0.0265 0.0529,0.0529 0.0265,0.0265 0.0265,0.0529 v 0.0794 l -0.0265,0.0529 v 0.0265 0.0529 l -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.10583 v 0.0265 0.0529 l 0.0265,0.0265 h 0.0529 l 0.15875,0.0265 0.0265,0.0265 H 115.2 l 0.0265,0.0265 v 0.0265 l 0.15875,0.29104 0.10583,0.21167 v 0.0529 l 0.0265,0.0265 0.15875,0.10584 0.0265,0.0265 v 0.0529 l 0.0529,0.0794 v 0.0529 0.0529 0.0529 l -0.0264,0.10583 -0.0794,0.15875 -0.0265,0.13229 v 0.39688 0.15875 l -0.0265,0.13229 -0.0265,0.0265 -0.0794,0.15875 -0.18521,0.21166 -0.0794,0.0794 -0.0794,0.0265 -0.0265,0.0265 h -0.0264 l -0.0794,0.0264 h -0.0529 l -0.0794,-0.0264 -0.13229,0.21166 -1.24354,2.01084 -1.66688,2.7252 -2.2225,3.6248 -2.67229,-0.0265 -0.18521,0.23812 -0.0265,1.08479 -0.0265,2.83105 h -3.12208 -2.91041 -3.28084 v -0.0265 -6.87917 l -0.264584,-4.97417 0.02646,-0.0529 0.02646,-0.0794 0.15875,-0.29104 0.02646,-0.0265 v -0.0265 l 0.02646,-0.0264 h 0.05292 l 0.132292,-0.0265 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0529 v -0.0264 -0.0529 l -0.05292,-0.13229 v -0.10584 l 0.02646,-0.0794 0.132292,-0.29104 0.02646,-0.0794 v -0.0529 l -0.02646,-0.10584 -0.02646,-0.0794 0.02646,-0.0529 v -0.0529 l 0.211666,-0.39687 0.02646,-0.0529 v -0.0529 -0.23813 l 0.02646,-0.0529 v -0.0529 l 0.07937,-0.13229 0.02646,-0.0529 v -0.0265 -0.10583 -0.0794 -0.0265 l 0.02646,-0.0529 0.07938,-0.0794 0.02646,-0.0265 0.02646,-0.0529 0.02646,-0.0529 0.02646,-0.26458 0.132292,-0.29104 0.02646,-0.18521 0.02646,-0.21167 v -0.0529 l -0.02646,-0.0529 -0.02646,-0.13229 -0.02646,-0.10584 -0.02646,-0.13229 -0.132292,-0.23812 -0.02646,-0.0794 v -0.0529 -0.15875 l 0.02646,-0.15875 v -0.1852 -0.18521 l -0.02646,-0.0794 v -0.0794 h -0.02646 l -0.02646,-0.0265 -0.05292,-0.0265 h -0.02646 -0.02646 l -0.105833,0.0265 -0.555625,0.15875 -0.582084,0.0794 -0.07937,-0.0265 -0.02646,-0.0265 -0.02646,-0.0265 v -0.0529 -0.0529 -0.26459 l -0.02646,-0.0794 v -0.0265 l -0.07937,-0.13229 -0.05292,-0.10584 -0.05292,-0.13229 v -0.0794 -0.0794 -0.10583 -0.0265 l -0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.0529 -0.15875,-0.13229 -0.15875,-0.18521 -1.534583,-0.9525 -0.264584,-0.13229 -0.185208,-0.0529 h -0.47625 v -1.69334 l -0.02646,-2.24896 v -1.11125 l 0.05292,-0.39687 0.370417,-1.08479 0.07937,-0.21167 0.105833,-0.3175 0.449792,-1.34937 v -0.10584 l 0.05292,-0.18521 0.132291,-0.44979 0.07937,-0.23812 0.02646,-0.0794 0.105833,-0.18521 0.07937,-0.1852 z"
|
||||
id="ARX"
|
||||
name="Córdoba"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
23
Elecciones-Web/frontend/public/maps/provincias-svg/jujuy.svg
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.908112mm"
|
||||
height="29.739168mm"
|
||||
viewBox="0 0 26.908112 29.739168"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-91.281242,-133.61455)">
|
||||
<path
|
||||
d="m 91.545834,150.99771 v -5.00063 l -0.05292,-3.78354 -0.15875,-0.82021 -0.02646,-0.0794 -0.02646,-0.10584 0.02646,-0.29104 0.05292,-0.44979 0.07937,-0.0794 0.105834,-0.0529 h 6.085416 0.423334 2.434162 3.01625 0.21167 4.89479 0.21167 0.0264 v -1.05833 l 0.0265,-5.66209 h 3.28084 2.91041 3.12208 v 1.82563 1.50812 l -0.0264,3.04271 v 0.23813 2.83104 1.53458 1.53458 2.9898 l 0.0264,3.09562 -0.0264,0.3175 0.0264,3.41313 v 3.88937 l -0.0264,3.51896 -0.47625,-0.0529 -0.21167,-0.0794 -0.15875,-0.15875 -0.71437,-0.50271 -0.18521,-0.0794 -0.10584,-0.13229 -0.13229,-0.23812 -0.0794,-0.0794 -0.0794,-0.0529 -0.21166,-0.1323 -0.37042,-0.0794 -0.10583,-0.0529 -0.47625,-0.58208 -0.0794,-0.0794 -0.26458,-0.0265 -0.10584,-0.0529 -0.39687,-0.26458 -1.77271,-0.68792 h -0.39687 l -1.50813,-0.29104 -0.10583,0.0529 -0.18521,-0.0529 -0.39688,0.0529 -0.13229,-0.10583 h -0.0529 -0.74084 l -0.82021,0.15875 -0.26458,-0.0265 -0.23812,0.10584 h -0.0794 l -0.0794,-0.0265 -0.21167,-0.15875 -0.47625,-0.18521 -0.29104,-0.0529 h -0.15875 l -0.10584,0.0794 -0.0794,0.0794 -0.13229,0.0794 -0.10584,0.0529 h -0.10583 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0529,-0.0529 -0.0794,-0.0265 h -0.23813 -0.0529 l -0.10583,-0.10583 -0.0529,-0.0265 -0.0794,-0.0265 -0.21167,-0.0265 -0.18521,-0.0794 -0.15875,-0.0265 -0.10583,-0.0794 h -0.0529 l -1.40229,-0.18521 -0.74083,0.13229 h -0.23813 l -0.635,-0.18521 h -0.15875 l -0.13229,-0.0529 -0.0529,-0.10584 -0.0529,-0.34396 -0.0529,-0.21166 -0.13229,-0.18521 -0.18521,-0.15875 -0.18521,-0.10583 -0.238117,-0.0529 -0.05292,-0.0265 -0.105833,-0.0794 -0.02646,-0.0265 -0.05292,-0.0265 -0.211667,-0.13229 -0.264583,-0.0265 -0.105834,-0.0529 -0.185208,-0.13229 -0.238125,-0.0529 -0.555625,-0.29104 h -0.05292 l -0.02646,-0.0529 -0.07937,-0.10584 -0.07937,-0.0794 -0.211667,-0.29104 -0.132291,-0.37042 v -0.0794 -0.15875 l -0.02646,-0.10583 -0.05292,-0.10583 -0.132292,-0.0529 h -0.105833 l -0.47625,0.0794 -0.238125,0.15875 -0.15875,0.0529 -0.396875,-0.0529 -0.15875,0.0794 -0.396875,0.0265 -0.211667,-0.10583 -0.185208,-0.18521 -0.15875,-0.21167 -0.291042,-0.635 -0.15875,-0.15875 -0.211667,-0.10583 h -0.05292 l -0.15875,0.0529 h -0.05292 l -0.07937,-0.0794 h -0.05292 l -0.05292,-0.0529 -0.05292,-0.15875 -0.02646,-0.13229 -0.02646,-0.13229 0.02646,-0.34396 0.132291,-0.23812 0.15875,-0.15875 0.370417,-0.26459 0.132292,-0.18521 0.07937,-0.26458 v -0.3175 l -0.07937,-0.21167 -0.132292,-0.23812 -0.15875,-0.21167 -0.15875,-0.13229 -0.238125,-0.13229 z"
|
||||
id="ARL"
|
||||
name="La Pampa"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
23
Elecciones-Web/frontend/public/maps/provincias-svg/salta.svg
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="13.838294mm"
|
||||
height="27.438555mm"
|
||||
viewBox="0 0 13.838294 27.438555"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-97.895573,-134.6725)">
|
||||
<path
|
||||
d="m 101.54708,134.85812 0.37042,0.0794 0.13229,0.0529 0.15875,0.0794 0.0529,0.0265 h 0.0265 0.0529 0.10584 l 0.10583,-0.0529 0.0529,-0.0265 0.0265,0.0265 0.0529,0.0265 0.13229,0.0794 h 0.0265 l 0.29104,-0.0529 h 0.0529 l 0.0529,0.0265 0.0794,0.0265 0.13229,0.0794 h 0.0529 0.0529 l 0.1852,-0.0529 h 0.0794 l 0.26458,0.0265 0.26459,-0.0265 0.29104,-0.10583 0.21166,-0.13229 0.1323,-0.0529 0.26458,-0.0529 h 0.21167 l 0.26458,0.0529 h 0.0529 l 0.0264,0.0265 0.0794,0.0529 0.0529,0.0265 h 0.0264 l 0.10584,0.0265 h 0.26458 l 0.52917,-0.10583 h 0.47625 l 0.18521,0.0529 0.26458,0.13229 1.53458,0.9525 0.15875,0.18521 0.15875,0.13229 0.0265,0.0529 0.0265,0.0529 0.0265,0.10584 v 0.0264 0.10584 0.0794 0.0794 l 0.0529,0.13229 0.0529,0.10583 0.0794,0.13229 v 0.0265 l 0.0264,0.0794 v 0.26458 0.0529 0.0529 l 0.0265,0.0265 0.0265,0.0265 0.0794,0.0265 0.58208,-0.0794 0.55562,-0.15875 0.10584,-0.0265 h 0.0265 0.0264 l 0.0529,0.0265 0.0265,0.0265 h 0.0265 v 0.0794 l 0.0265,0.0794 v 0.18521 0.18521 l -0.0265,0.15875 v 0.15875 0.0529 l 0.0265,0.0794 0.13229,0.23813 0.0265,0.13229 0.0265,0.10583 0.0265,0.13229 0.0265,0.0529 v 0.0529 l -0.0265,0.21166 -0.0265,0.18521 -0.13229,0.29104 -0.0265,0.26459 -0.0265,0.0529 -0.0265,0.0529 -0.0265,0.0265 -0.0794,0.0794 -0.0264,0.0529 v 0.0265 0.0794 0.10584 0.0264 l -0.0265,0.0529 -0.0794,0.13229 v 0.0529 l -0.0265,0.0529 v 0.23812 0.0529 l -0.0264,0.0529 -0.21167,0.39688 v 0.0529 l -0.0265,0.0529 0.0265,0.0794 0.0265,0.10583 v 0.0529 l -0.0265,0.0794 -0.13229,0.29104 -0.0265,0.0794 v 0.10583 l 0.0529,0.13229 v 0.0529 0.0265 l -0.0265,0.0529 -0.0529,0.0265 -0.0265,0.0265 -0.13229,0.0265 h -0.0529 l -0.0265,0.0265 v 0.0265 l -0.0265,0.0265 -0.15875,0.29104 -0.0265,0.0794 -0.0265,0.0529 0.26458,4.97417 v 6.87916 0.0265 l -0.0265,5.66208 v 1.05834 h -0.0265 -0.21167 -4.89479 -0.21167 -3.01625 l -0.0529,-0.0265 -0.0265,-0.21167 v -0.10583 l 0.0794,-0.3175 0.0265,-0.50271 0.23813,-1.05833 0.0529,-0.68792 0.18521,-0.37042 0.0265,-0.13229 v -0.60854 l 0.0794,-0.42333 v -0.10584 l -0.0794,-0.42333 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 v -0.0794 l -0.0265,-0.0529 -0.0265,-0.0529 -0.0529,-0.0794 0.10583,-0.39688 V 156.21 l -0.0265,-0.10583 -0.10584,-0.21167 -0.0265,-0.0794 -0.0265,-0.10583 -0.0794,-0.26458 v -0.0794 l 0.0529,-0.34396 -0.0265,-0.13229 -0.13229,-0.50271 -0.66146,-1.29645 -0.23813,-0.29105 -0.0529,-0.10583 -0.0265,-0.13229 -0.13229,-0.42333 -0.0265,-0.58209 -0.0529,-0.13229 -0.0794,-0.0529 -0.0529,-0.10583 0.0794,-0.13229 -0.0794,-0.13229 V 150.495 l -0.0529,-0.15875 v -0.0794 l 0.0794,-0.13229 0.0265,-0.10583 0.0265,-0.0265 h 0.0264 l 0.0794,0.0529 h 0.0265 l 0.0794,-0.10584 0.0265,-0.13229 0.0265,-0.34396 0.0265,-0.21166 v -0.10584 l -0.10584,-0.15875 -0.0264,-0.21166 -0.0529,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 -0.0794,-0.10583 -0.0529,-0.29104 -0.0529,-0.1323 -0.10583,-0.0794 -0.0794,-0.0794 -0.18521,-0.13229 -0.10583,-0.0794 -0.0529,-0.0794 v -0.13229 l -0.13229,-0.44979 -0.13229,-0.21167 -0.0794,-0.29104 -0.0265,-0.0794 -0.07938,-0.21167 -0.02646,-0.0794 -0.105833,-0.10583 -0.529167,-0.89958 -0.05292,-0.15875 v -0.13229 -0.29105 -0.10583 l -0.132291,-0.3175 v -0.23812 l -0.05292,-0.0529 -0.02646,-0.18521 0.02646,-0.50271 -0.02646,-0.60854 -0.05292,-0.13229 -0.02646,-0.0529 v -0.0794 l 0.02646,-0.13229 v -0.0794 l -0.105833,-0.26458 -0.05292,-0.18521 0.02646,-0.0794 0.105834,-0.0794 v -0.13229 l -0.07937,-0.29104 0.02646,-0.0529 0.02646,-0.18521 0.105834,-0.18521 -0.02646,-0.0794 -0.05292,-0.0794 -0.02646,-0.10583 -0.02646,-0.10583 -0.05292,-0.0529 -0.05292,-0.0529 -0.05292,-0.0794 -0.05292,-0.0529 -0.02646,-0.10583 -0.105833,-0.52917 -0.02646,-0.0794 v -0.10584 l -0.07937,-0.18521 -0.02646,-0.1852 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0529 -0.02646,-0.0265 v -0.10584 -0.0529 l -0.05292,-0.0794 -0.05292,-0.0529 -0.132291,-0.10584 -0.132292,-0.58208 -0.02646,-0.29104 v -0.42334 -0.21166 -0.39688 -0.10583 l -0.02646,-0.13229 0.02646,-0.0529 v -0.0794 l 0.07937,-0.29105 0.02646,-0.0265 v -0.0529 l -0.02646,-0.0794 v -0.0529 l -0.02646,-0.0265 v -0.0265 l 0.02646,-0.10583 -0.02646,-0.0529 -0.02646,-0.15875 v -0.0794 -0.0265 -0.0529 l 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.132292,-0.0794 0.238125,0.0529 0.15875,0.0529 h 0.07937 l 0.132292,0.0265 0.449791,-0.0529 0.185209,-0.0529 h 0.05292 0.07937 l 0.105834,0.0265 0.07937,0.0265 0.05292,0.0265 0.05292,0.0794 h 0.02646 l 0.02646,0.0265 h 0.05292 l 0.185209,0.0529 z"
|
||||
id="ARD"
|
||||
name="San Luis"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="19.023661mm"
|
||||
height="28.363354mm"
|
||||
viewBox="0 0 19.023661 28.363354"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-95.24991,-134.40833)">
|
||||
<path
|
||||
d="m 99.059995,138.29771 0.02646,-0.21167 0.02646,-0.0265 0.185208,-0.39687 0.05292,-0.10584 v -0.0529 -0.21167 l 0.02646,-0.0529 0.02646,-0.10583 1.164167,-2.59292 0.10583,-0.13229 0.21167,0.0265 1.05833,0.34396 0.18521,0.0529 2.88396,0.0529 h 9.02229 l 0.15875,0.50271 0.0794,2.46063 v 0.68791 3.28084 6.27062 1.16417 l -0.3175,2.2225 -0.52917,3.4925 -0.47625,3.04271 -0.26458,1.66687 -0.47625,3.09563 -0.84667,-2.2225 -0.26458,-0.15875 -0.89959,-0.0529 -1.85208,0.0265 h -3.12208 l -0.1323,-0.0265 -0.0264,-0.0529 -0.0265,-0.0265 v -0.10583 -0.0529 -0.0264 l -0.0529,-0.0265 -0.0529,-0.0265 -0.1852,-0.0265 h -0.0529 l -0.0265,-0.0529 v -0.0264 l 0.0265,-0.18521 v -0.0265 -0.0265 -0.0265 l -0.0265,-0.0265 -0.0265,-0.0265 h -0.0529 l -0.21167,0.0529 -0.0529,-0.0265 -0.39687,-0.10583 -0.0794,-0.0265 -0.635,0.10584 h -0.10583 l -0.3175,-0.0265 h -0.0529 l -0.0529,-0.0265 -0.0529,-0.0529 -0.0794,-0.0529 -0.0265,-0.0265 -0.0529,-0.0265 -0.34396,-0.0529 -0.0265,-0.0265 -0.0529,-0.0529 -0.0529,-0.0794 -0.0265,-0.0265 -0.39687,-0.23812 -0.0794,-0.0529 -0.0265,-0.0529 v -0.0794 l 0.0265,-0.15875 -0.0265,-0.0794 v -0.0265 l -0.0529,-0.0265 -1.08479,-0.26458 h -0.21167 l -0.608543,0.13229 -1.031875,0.29104 -1.5875,0.39687 -0.132292,-0.15875 -0.05292,-0.13229 V 158.67 l -0.05292,-0.13229 -0.370417,-0.635 -0.238125,-0.58208 -0.238125,-1.82563 0.02646,-0.52916 -0.105833,-1.21709 v -0.10583 l -0.02646,-0.0265 -0.02646,-0.0265 -0.105833,-0.10583 -0.211667,-0.13229 h -0.05292 l -0.02646,-0.0265 v -0.0265 l -0.02646,-0.0265 v -0.21166 -0.0529 l 0.02646,-0.0265 0.105834,-0.0529 0.02646,-0.0265 0.05292,-0.13229 0.07937,-0.1323 0.132291,-0.13229 0.07937,-0.10583 0.05292,-0.15875 0.05292,-0.34396 -0.05292,-0.92604 -0.47625,-2.27542 0.05292,-0.0529 0.07937,-0.0265 h 0.02646 0.05292 l 0.07937,0.0265 h 0.05292 0.07937 l 0.05292,-0.0265 0.05292,-0.0529 0.05292,-0.10583 0.15875,-0.50271 0.05292,-0.0529 0.02646,-0.0265 h 0.07937 0.02646 l -0.02646,-0.0794 -0.132291,-0.3175 -0.238125,-0.55563 v -0.13229 l 0.05292,-0.18521 h 0.05292 l 0.02646,-0.0264 h 0.105834 0.05292 0.02646 0.02646 l 0.02646,-0.0265 0.05292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.05292,-0.10583 0.02646,-0.0529 v -0.0529 h -0.02646 l -0.105833,-0.0794 -0.105833,-0.0529 h -0.07937 l -0.105834,-0.0265 -0.07937,-0.0529 -0.02646,-0.0265 0.343958,-0.39687 0.07937,-0.0794 0.264584,-0.3175 0.07937,-0.0794 0.05292,-0.0265 0.132292,-0.0265 0.02646,-0.0265 0.02646,-0.0529 0.132291,-0.37042 0.02646,-0.0265 0.105833,-0.13229 0.15875,-0.44979 0.05292,-0.26459 0.07937,-0.34395 0.02646,-0.0529 0.02646,-0.0529 0.05292,-0.0529 0.02646,-0.0529 h 0.05292 l 0.02646,-0.0265 0.07937,-0.15875 0.185209,-0.76729 0.07937,-0.26458 0.02646,-0.10584 H 98.081 l 0.02646,-0.0265 h 0.07937 0.07937 0.02646 l 0.02646,-0.0529 0.02646,-0.0529 0.15875,-0.58208 0.02646,-0.0529 0.07937,-0.0265 0.343959,0.0529 0.02646,-0.0265 v -0.10583 -0.23813 l -0.02646,-0.44979 -0.105834,-0.635 v -0.0529 l 0.02646,-0.0529 0.185208,-0.66146 0.02646,-0.18521 z"
|
||||
id="ARG"
|
||||
name="Santiago del Estero"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="9.3935156mm"
|
||||
height="12.197932mm"
|
||||
viewBox="0 0 9.3935156 12.197932"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-100.01234,-142.34548)">
|
||||
<path
|
||||
d="m 100.77979,143.45708 1.61396,0.26459 0.1852,0.0265 0.0529,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0265,-0.0265 0.0264,-0.0529 v -0.0265 -0.0265 -0.0529 -0.21167 -0.0794 l 0.0265,-0.13229 0.0529,-0.10584 v -0.13229 -0.23812 -0.0794 l 0.0265,-0.0794 v -0.0265 l 0.0265,-0.0265 0.0529,-0.0265 h 0.0265 l 0.0265,0.0265 0.13229,0.0529 0.0265,0.0265 0.26458,0.0794 0.13229,-0.0265 h 0.0794 l 0.52917,0.13229 h 0.0794 0.0265 l 0.0265,-0.0264 0.29104,-0.15875 0.15875,-0.0529 0.0529,-0.0265 h 0.0529 l 0.0265,0.0265 h 0.0265 l 0.0265,0.0265 0.0794,0.15875 0.0265,0.15875 0.0529,0.0794 0.0265,0.0794 0.0794,0.0529 1.05833,0.42334 0.0794,-0.0265 h 0.0529 l 0.10583,0.0265 0.10583,0.0794 0.18521,0.0794 0.10583,0.0265 0.0794,0.0265 0.0265,-0.0265 h 0.0794 l 0.0529,-0.0265 h 0.0265 l 0.10583,-0.0794 0.10583,-0.10583 0.0529,-0.0265 0.26458,-0.10583 0.18521,-0.0265 0.29104,0.0794 h 0.0794 l 1.16417,-0.0265 0.0264,0.42333 -0.0264,0.18521 -0.18521,0.66146 -0.0265,0.0529 v 0.0529 l 0.10583,0.635 0.0265,0.44979 v 0.23813 0.10583 l -0.0265,0.0265 -0.34396,-0.0529 -0.0794,0.0265 -0.0265,0.0529 -0.15875,0.58208 -0.0265,0.0529 -0.0265,0.0529 h -0.0265 -0.0794 -0.0794 l -0.0265,0.0265 h -0.0529 l -0.0265,0.10583 -0.0794,0.26459 -0.18521,0.76729 -0.0794,0.15875 -0.0264,0.0265 h -0.0529 l -0.0265,0.0529 -0.0529,0.0529 -0.0264,0.0529 -0.0265,0.0529 -0.0794,0.34396 -0.0529,0.26459 -0.15875,0.44979 -0.10584,0.13229 -0.0265,0.0265 -0.13229,0.37041 -0.0265,0.0529 -0.0264,0.0265 -0.1323,0.0265 -0.0529,0.0265 -0.0794,0.0794 -0.26458,0.3175 -0.0794,0.0794 -0.34395,0.39687 0.0264,0.0265 0.0794,0.0529 0.10583,0.0265 h 0.0794 l 0.10583,0.0529 0.10583,0.0794 h 0.0265 v 0.0529 l -0.0265,0.0529 -0.0529,0.10583 -0.0265,0.0529 -0.0265,0.0265 -0.0529,0.0265 -0.0265,0.0265 h -0.0265 -0.0265 -0.0529 -0.10583 l -0.0265,0.0265 h -0.0529 l -0.0529,0.18521 v 0.13229 l 0.23812,0.55563 0.13229,0.3175 0.0265,0.0794 h -0.0265 -0.0794 l -0.0265,0.0265 -0.0529,0.0529 -0.15875,0.50271 -0.0529,0.10583 -0.0529,0.0529 -0.0529,0.0265 h -0.0794 -0.0529 l -0.0794,-0.0265 h -0.0529 -0.0265 l -0.0794,0.0265 -0.0529,0.0529 -0.15875,0.0794 h -0.10583 -0.0794 l -0.0794,-0.0265 -0.10584,-0.0529 -0.10583,-0.0794 -0.10583,-0.10584 -0.0794,-0.0529 -0.0794,-0.0529 h -0.0529 -0.0529 l -0.44979,0.29104 -0.15875,0.18521 -0.18521,0.13229 -0.0529,0.0265 -0.13229,0.21167 -0.1323,0.29104 -0.0265,0.0529 -0.0794,-0.0264 -0.44979,-0.58209 -0.0529,-0.0794 -0.0794,-0.23813 -0.0529,-0.3175 -0.0529,-0.18521 -0.0264,-0.0265 -0.0529,-0.0529 -0.23813,-0.13229 h -0.10583 l -0.0529,0.0265 -0.0529,0.0265 -0.13229,0.0529 -0.10584,-0.0794 -0.0265,-0.0265 -0.0529,-0.0529 -0.0265,-0.0794 v -0.1323 l -0.0794,-0.23812 -0.0264,-0.0794 -0.0265,-0.0264 -0.0794,-0.0529 -0.0529,-0.0265 -0.0794,-0.0794 -0.0265,-0.0265 -0.0265,-0.0529 -0.0264,-0.0529 -0.0265,-0.26458 -0.0794,-0.15875 -0.18521,-0.89958 0.0529,-0.15875 v -0.0265 l -0.0265,-0.0529 h -0.0265 -0.0264 l -0.15875,-0.0265 -0.39688,-0.23812 -0.0794,-0.0265 -0.18521,-0.0265 -0.10584,-0.0265 -0.10583,-0.0264 -0.0529,-0.0529 -0.0264,-0.0265 v -0.0265 l 0.0264,-0.0265 0.1323,-0.21166 0.10583,-0.23813 0.15875,-0.18521 0.13229,-0.10583 0.10583,-0.13229 0.0265,-0.0529 0.0265,-0.0265 0.0265,-0.0529 0.0265,-0.0794 0.10583,-0.18521 0.13229,-0.1852 0.18521,-0.18521 0.37042,-0.39688 0.15875,-0.23812 0.0529,-0.15875 0.0265,-0.0794 v -0.0794 -0.34396 -0.0794 l 0.0794,-0.37041 v -0.0529 -0.0794 -0.0529 -0.0529 l -0.0265,-0.0265 v -0.0264 l -0.0265,-0.0529 h -0.0265 l -0.0265,-0.0265 -0.23812,-0.13229 -0.37042,-0.29104 -0.18521,-0.18521 -0.0794,-0.0529 -0.13229,-0.0529 -0.13229,-0.0529 h -0.10584 l -0.13229,-0.0529 -0.0794,-0.0529 -0.0794,-0.1323 -0.0265,-0.0794 v -0.0529 -0.10583 l 0.10584,-0.29104 0.13229,-0.26459 0.0265,-0.15875 0.0264,-0.13229 v -0.10583 l 0.0265,-0.13229 0.0265,-0.0794 0.0794,-0.0794 0.0529,-0.0794 0.0529,-0.0265 z"
|
||||
id="ART"
|
||||
name="Tucumán"
|
||||
style="stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -1,29 +1,32 @@
|
||||
#root {
|
||||
/* src/App.css */
|
||||
|
||||
.container-legislativas2025 {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
@keyframes elecciones-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
.container-legislativas2025 a:nth-of-type(2) .logo {
|
||||
animation: elecciones-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
.container-legislativas2025 .card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
.container-legislativas2025 .read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// src/App.tsx
|
||||
import './App.css'
|
||||
import { BancasWidget } from './components/BancasWidget'
|
||||
/*import { BancasWidget } from './components/BancasWidget'
|
||||
import { CongresoWidget } from './components/CongresoWidget'
|
||||
import MapaBsAs from './components/MapaBsAs'
|
||||
import { DipSenTickerWidget } from './components/DipSenTickerWidget'
|
||||
@@ -18,10 +17,11 @@ import { SenadoresPorSeccionWidget } from './components/SenadoresPorSeccionWidge
|
||||
import { ConcejalesPorSeccionWidget } from './components/ConcejalesPorSeccionWidget'
|
||||
import { ResultadosTablaDetalladaWidget } from './components/ResultadosTablaDetalladaWidget'
|
||||
import { ResultadosRankingMunicipioWidget } from './components/ResultadosRankingMunicipioWidget'
|
||||
|
||||
*/
|
||||
function App() {
|
||||
return (
|
||||
return ({/*
|
||||
<>
|
||||
|
||||
<h1>Resultados Electorales - Provincia de Buenos Aires</h1>
|
||||
<main className="space-y-6">
|
||||
<ResumenGeneralWidget />
|
||||
@@ -60,7 +60,7 @@ function App() {
|
||||
<hr className="border-gray-300" />
|
||||
<ResultadosRankingMunicipioWidget />
|
||||
</main>
|
||||
</>
|
||||
</>*/}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// src/apiService.ts
|
||||
import axios from 'axios';
|
||||
import type { ApiResponseRankingMunicipio, ApiResponseRankingSeccion, ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple, TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker, ApiResponseResultadosPorSeccion } from './types/types';
|
||||
import type {
|
||||
ApiResponseRankingMunicipio, ApiResponseRankingSeccion,
|
||||
ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple,
|
||||
TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker,
|
||||
ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia,
|
||||
CategoriaResumenHome, ResultadoFila, ResultadoSeccion,
|
||||
ProvinciaResumen
|
||||
} from './types/types';
|
||||
|
||||
/**
|
||||
* URL base para las llamadas a la API.
|
||||
@@ -73,7 +80,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 {
|
||||
@@ -84,14 +90,46 @@ export interface ResultadoDetalleSeccion {
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
export const getResumenProvincial = async (): Promise<CategoriaResumen[]> => {
|
||||
const response = await apiClient.get('/resultados/provincia/02');
|
||||
export interface PartidoComposicionNacional {
|
||||
id: string;
|
||||
nombre: string;
|
||||
nombreCorto: string | null;
|
||||
color: string | null;
|
||||
bancasFijos: number;
|
||||
bancasGanadas: number;
|
||||
bancasTotales: number;
|
||||
ordenDiputadosNacionales: number | null;
|
||||
ordenSenadoresNacionales: number | null;
|
||||
}
|
||||
|
||||
export interface CamaraComposicionNacional {
|
||||
camaraNombre: string;
|
||||
totalBancas: number;
|
||||
bancasEnJuego: number;
|
||||
partidos: PartidoComposicionNacional[];
|
||||
presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null;
|
||||
ultimaActualizacion: string;
|
||||
}
|
||||
|
||||
export interface ComposicionNacionalData {
|
||||
diputados: CamaraComposicionNacional;
|
||||
senadores: CamaraComposicionNacional;
|
||||
}
|
||||
|
||||
export interface ResumenParams {
|
||||
focoDistritoId?: string;
|
||||
focoCategoriaId?: number;
|
||||
cantidadResultados?: number;
|
||||
}
|
||||
|
||||
export const getResumenProvincial = async (eleccionId: number): Promise<CategoriaResumen[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/provincia/02`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBancasPorSeccion = async (seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => {
|
||||
const { data } = await apiClient.get(`/resultados/bancas-por-seccion/${seccionId}/${camara}`);
|
||||
return data;
|
||||
export const getBancasPorSeccion = async (eleccionId: number, seccionId: string, camara: 'diputados' | 'senadores'): Promise<ProyeccionBancas> => {
|
||||
const { data } = await apiClient.get(`/elecciones/${eleccionId}/bancas-por-seccion/${seccionId}/${camara}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -140,13 +178,13 @@ export const getMesasPorEstablecimiento = async (establecimientoId: string): Pro
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getComposicionCongreso = async (): Promise<ComposicionData> => {
|
||||
const response = await apiClient.get('/resultados/composicion-congreso');
|
||||
export const getComposicionCongreso = async (eleccionId: number): Promise<ComposicionData> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/composicion-congreso`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getBancadasDetalle = async (): Promise<BancadaDetalle[]> => {
|
||||
const response = await apiClient.get('/resultados/bancadas-detalle');
|
||||
export const getBancadasDetalle = async (eleccionId: number): Promise<BancadaDetalle[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/bancadas-detalle`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -155,24 +193,18 @@ export const getConfiguracionPublica = async (): Promise<ConfiguracionPublica> =
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getResultadosPorSeccion = async (seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => {
|
||||
const response = await apiClient.get(`/resultados/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`);
|
||||
export const getResultadosPorSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ApiResponseResultadosPorSeccion> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/seccion-resultados/${seccionId}?categoriaId=${categoriaId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDetalleSeccion = async (seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => {
|
||||
const response = await apiClient.get(`/resultados/seccion/${seccionId}?categoriaId=${categoriaId}`);
|
||||
export const getDetalleSeccion = async (eleccionId: number, seccionId: string, categoriaId: number): Promise<ResultadoDetalleSeccion[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/seccion/${seccionId}?categoriaId=${categoriaId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getResultadosPorMunicipioYCategoria = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
|
||||
const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`);
|
||||
return response.data.resultados;
|
||||
};
|
||||
|
||||
export const getResultadosPorMunicipio = async (municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
|
||||
const response = await apiClient.get(`/resultados/partido/${municipioId}?categoriaId=${categoriaId}`);
|
||||
// La respuesta es un objeto, nosotros extraemos el array de resultados
|
||||
export const getResultadosPorMunicipio = async (eleccionId: number, municipioId: string, categoriaId: number): Promise<ResultadoTicker[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/partido/${municipioId}?categoriaId=${categoriaId}`);
|
||||
return response.data.resultados;
|
||||
};
|
||||
|
||||
@@ -214,3 +246,116 @@ export const getEstablecimientosPorMunicipio = async (municipioId: string): Prom
|
||||
const response = await apiClient.get(`/catalogos/establecimientos-por-municipio/${municipioId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPanelElectoral = async (
|
||||
eleccionId: number,
|
||||
ambitoId: string | null,
|
||||
categoriaId: number,
|
||||
nivel: 'pais' | 'provincia' | 'municipio'
|
||||
): Promise<PanelElectoralDto> => {
|
||||
|
||||
let url: string;
|
||||
|
||||
// Construimos la URL con el prefijo correcto.
|
||||
if (nivel === 'pais' || !ambitoId) {
|
||||
url = `/elecciones/${eleccionId}/panel`;
|
||||
} else if (nivel === 'provincia') {
|
||||
url = `/elecciones/${eleccionId}/panel/distrito:${ambitoId}`;
|
||||
} else { // nivel === 'municipio'
|
||||
url = `/elecciones/${eleccionId}/panel/municipio:${ambitoId}`;
|
||||
}
|
||||
|
||||
url += `?categoriaId=${categoriaId}`;
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.warn(`API devolvió 404 para ${url}. Devolviendo un estado vacío.`);
|
||||
return {
|
||||
ambitoNombre: 'Sin Datos',
|
||||
mapaData: [],
|
||||
resultadosPanel: [],
|
||||
estadoRecuento: { participacionPorcentaje: 0, mesasTotalizadasPorcentaje: 0 },
|
||||
sinDatos: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getComposicionNacional = async (eleccionId: number): Promise<ComposicionNacionalData> => {
|
||||
const { data } = await apiClient.get(`/elecciones/${eleccionId}/composicion-nacional`);
|
||||
return data;
|
||||
};
|
||||
|
||||
// 11. Endpoint para el widget de tarjetas nacionales
|
||||
export const getResumenPorProvincia = async (eleccionId: number, params: ResumenParams = {}): Promise<ResumenProvincia[]> => {
|
||||
// Usamos URLSearchParams para construir la query string de forma segura y limpia
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.focoDistritoId) {
|
||||
queryParams.append('focoDistritoId', params.focoDistritoId);
|
||||
}
|
||||
if (params.focoCategoriaId) {
|
||||
queryParams.append('focoCategoriaId', params.focoCategoriaId.toString());
|
||||
}
|
||||
if (params.cantidadResultados) {
|
||||
queryParams.append('cantidadResultados', params.cantidadResultados.toString());
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
|
||||
// Añadimos la query string a la URL solo si tiene contenido
|
||||
const url = `/elecciones/${eleccionId}/resumen-por-provincia${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getMunicipiosPorDistrito = async (distritoId: string): Promise<CatalogoItem[]> => {
|
||||
const response = await apiClient.get(`/catalogos/municipios-por-distrito/${distritoId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getHomeResumen = async (eleccionId: number, distritoId: string, categoriaId: number): Promise<CategoriaResumenHome> => {
|
||||
const queryParams = new URLSearchParams({
|
||||
eleccionId: eleccionId.toString(),
|
||||
distritoId: distritoId,
|
||||
categoriaId: categoriaId.toString(),
|
||||
});
|
||||
const url = `/elecciones/home-resumen?${queryParams.toString()}`;
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getHomeResumenNacional = async (eleccionId: number, categoriaId: number): Promise<CategoriaResumenHome> => {
|
||||
const queryParams = new URLSearchParams({
|
||||
eleccionId: eleccionId.toString(),
|
||||
categoriaId: categoriaId.toString(),
|
||||
});
|
||||
const url = `/elecciones/home-resumen-nacional?${queryParams.toString()}`;
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getTablaConurbano = async (eleccionId: number): Promise<ResultadoFila[]> => {
|
||||
const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-conurbano`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getTablaSecciones = async (eleccionId: number): Promise<ResultadoSeccion[]> => {
|
||||
const { data } = await apiClient.get(`/elecciones/${eleccionId}/tabla-secciones`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getResumenNacionalPorProvincia = async (eleccionId: number, categoriaId: number): Promise<ProvinciaResumen[]> => {
|
||||
const response = await apiClient.get(`/elecciones/${eleccionId}/resumen-nacional-por-provincia?categoriaId=${categoriaId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getProvincias = async (): Promise<CatalogoItem[]> => {
|
||||
const response = await apiClient.get('/catalogos/provincias');
|
||||
return response.data;
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
/* src/components/CongresoWidget.css */
|
||||
.congreso-container {
|
||||
display: flex;
|
||||
/* Se reduce ligeramente el espacio entre el gráfico y el panel */
|
||||
gap: 1rem;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-family: "Public Sans", system-ui, sans-serif;
|
||||
color: #333333;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.congreso-grafico {
|
||||
/* --- CAMBIO PRINCIPAL: Se aumenta la proporción del gráfico --- */
|
||||
flex: 1 1 65%;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.congreso-grafico svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
animation: fadeIn 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.congreso-summary {
|
||||
/* --- CAMBIO PRINCIPAL: Se reduce la proporción del panel de datos --- */
|
||||
flex: 1 1 35%;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
/* Se reduce el padding para dar aún más espacio al gráfico */
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.congreso-summary h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.4em;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.chamber-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chamber-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: none;
|
||||
background-color: #f8f9fa;
|
||||
color: #6c757d;
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.chamber-tabs button:first-child {
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.chamber-tabs button:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.chamber-tabs button.active {
|
||||
background-color: var(--primary-accent-color);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.summary-metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.summary-metric strong {
|
||||
font-size: 1.5em;
|
||||
font-weight: 700;
|
||||
color: var(--primary-accent-color);
|
||||
}
|
||||
|
||||
.congreso-summary hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.partido-lista {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.partido-lista li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.partido-color-box {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.partido-bancas {
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
/* --- Media Query para Responsividad Móvil --- */
|
||||
@media (max-width: 768px) {
|
||||
.congreso-container {
|
||||
flex-direction: column;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.congreso-summary {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.seat-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.seat-tooltip img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ccc;
|
||||
}
|
||||
|
||||
.seat-tooltip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.seat-tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
background-color: white;
|
||||
}
|
||||
.seat-tooltip img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ccc;
|
||||
}
|
||||
.seat-tooltip p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#seat-tooltip.react-tooltip {
|
||||
opacity: 1 !important;
|
||||
background-color: white; /* Opcional: asegura un fondo sólido */
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
// 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 { ResultadosTablaDetalladaWidget } from './ResultadosTablaDetalladaWidget'
|
||||
import { ResultadosRankingMunicipioWidget } from './ResultadosRankingMunicipioWidget'
|
||||
import '../App.css';
|
||||
// src/components/common/DevApp.tsx
|
||||
import { BancasWidget } from '../../features/legislativas/provinciales/BancasWidget'
|
||||
import { CongresoWidget } from '../../features/legislativas/provinciales/CongresoWidget'
|
||||
import MapaBsAs from '../../features/legislativas/provinciales/MapaBsAs'
|
||||
import { DipSenTickerWidget } from '../../features/legislativas/provinciales/DipSenTickerWidget'
|
||||
import { TelegramaWidget } from '../../features/legislativas/provinciales/TelegramaWidget'
|
||||
import { ConcejalesWidget } from '../../features/legislativas/provinciales/ConcejalesWidget'
|
||||
import MapaBsAsSecciones from '../../features/legislativas/provinciales/MapaBsAsSecciones'
|
||||
import { SenadoresWidget } from '../../features/legislativas/provinciales/SenadoresWidget'
|
||||
import { DiputadosWidget } from '../../features/legislativas/provinciales/DiputadosWidget'
|
||||
import { ResumenGeneralWidget } from '../../features/legislativas/provinciales/ResumenGeneralWidget'
|
||||
import { SenadoresTickerWidget } from '../../features/legislativas/provinciales/SenadoresTickerWidget'
|
||||
import { DiputadosTickerWidget } from '../../features/legislativas/provinciales/DiputadosTickerWidget'
|
||||
import { ConcejalesTickerWidget } from '../../features/legislativas/provinciales/ConcejalesTickerWidget'
|
||||
import { DiputadosPorSeccionWidget } from '../../features/legislativas/provinciales/DiputadosPorSeccionWidget'
|
||||
import { SenadoresPorSeccionWidget } from '../../features/legislativas/provinciales/SenadoresPorSeccionWidget'
|
||||
import { ConcejalesPorSeccionWidget } from '../../features/legislativas/provinciales/ConcejalesPorSeccionWidget'
|
||||
import { ResultadosTablaDetalladaWidget } from '../../features/legislativas/provinciales/ResultadosTablaDetalladaWidget'
|
||||
import { ResultadosRankingMunicipioWidget } from '../../features/legislativas/provinciales/ResultadosRankingMunicipioWidget'
|
||||
import '../../App.css';
|
||||
|
||||
|
||||
export const DevApp = () => {
|
||||
@@ -38,7 +38,7 @@ export const DevApp = () => {
|
||||
<DiputadosPorSeccionWidget />
|
||||
<SenadoresPorSeccionWidget />
|
||||
<ConcejalesPorSeccionWidget />
|
||||
<CongresoWidget />
|
||||
<CongresoWidget eleccionId={1} />
|
||||
<BancasWidget />
|
||||
<MapaBsAs />
|
||||
<MapaBsAsSecciones />
|
||||
@@ -0,0 +1,339 @@
|
||||
// src/components/common/DiputadosNacionalesLayout.tsx
|
||||
import React from 'react';
|
||||
import type { PartidoComposicionNacional } from '../../apiService';
|
||||
|
||||
// --- Interfaces Actualizadas ---
|
||||
interface DiputadosNacionalesLayoutProps {
|
||||
partyData: PartidoComposicionNacional[];
|
||||
size?: number;
|
||||
presidenteBancada?: { color: string | null } | null; // <-- Nueva Prop
|
||||
}
|
||||
|
||||
const PRESIDENTE_SEAT_INDEX = 0; // El escaño 'seat-0' es el del presidente
|
||||
|
||||
export const DiputadosNacionalesLayout: React.FC<DiputadosNacionalesLayoutProps> = ({
|
||||
partyData,
|
||||
size = 800,
|
||||
presidenteBancada, // <-- Recibimos la nueva prop
|
||||
}) => {
|
||||
// --- ARRAY DE 257 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" ---
|
||||
const seatElements = [
|
||||
<circle key="seat-0" id="seat-0" r="15.7" cy="639.5" cx="595.3" />,
|
||||
<circle key="seat-1" id="seat-1" r="15.7" cy="673.1" cx="109.3" />,
|
||||
<circle key="seat-2" id="seat-2" r="15.7" cy="673.1" cx="161.7" />,
|
||||
<circle key="seat-3" id="seat-3" r="15.7" cy="673.5" cx="214.3" />,
|
||||
<circle key="seat-4" id="seat-4" r="15.7" cy="673.2" cx="266.5" />,
|
||||
<circle key="seat-5" id="seat-5" r="15.7" cy="669.5" cx="319.4" />,
|
||||
<circle key="seat-6" id="seat-6" r="15.7" cy="660" cx="370.8" />,
|
||||
<circle key="seat-7" id="seat-7" transform="rotate(-88.1)" r="15.7" cy="77.69" cx="-634.1" />,
|
||||
<circle key="seat-8" id="seat-8" r="15.7" cy="639" cx="109.3" />,
|
||||
<circle key="seat-9" id="seat-9" r="15.7" cy="639" cx="161.7" />,
|
||||
<circle key="seat-10" id="seat-10" r="15.7" cy="639.2" cx="214.3" />,
|
||||
<circle key="seat-11" id="seat-11" r="15.7" cy="638.8" cx="266.7" />,
|
||||
<circle key="seat-12" id="seat-12" r="15.7" cy="635.1" cx="319.4" />,
|
||||
<circle key="seat-13" id="seat-13" r="15.7" cy="625.7" cx="371.7" />,
|
||||
<circle key="seat-14" id="seat-14" r="15.7" cy="639" cx="424.2" />,
|
||||
<circle key="seat-15" id="seat-15" transform="rotate(-88.1)" r="15.7" cy="77" cx="-600.18" />,
|
||||
<circle key="seat-16" id="seat-16" r="15.7" cy="600.9" cx="109.5" />,
|
||||
<circle key="seat-17" id="seat-17" r="15.7" cy="603.7" cx="162.1" />,
|
||||
<circle key="seat-18" id="seat-18" r="15.7" cy="598.6" cx="215" />,
|
||||
<circle key="seat-19" id="seat-19" r="15.7" cy="602.6" cx="267.1" />,
|
||||
<circle key="seat-20" id="seat-20" transform="rotate(-88.1)" r="15.7" cy="76.57" cx="-562.57" />,
|
||||
<circle key="seat-21" id="seat-21" r="15.7" cy="566.7" cx="112.2" />,
|
||||
<circle key="seat-22" id="seat-22" r="15.7" cy="570" cx="164.7" />,
|
||||
<circle key="seat-23" id="seat-23" r="15.7" cy="564.5" cx="218.2" />,
|
||||
<circle key="seat-24" id="seat-24" r="15.7" cy="568.6" cx="270.9" />,
|
||||
<circle key="seat-25" id="seat-25" r="15.7" cy="588" cx="321.1" />,
|
||||
<circle key="seat-26" id="seat-26" transform="rotate(-88.1)" r="15.7" cy="79.88" cx="-524.51" />,
|
||||
<circle key="seat-27" id="seat-27" transform="rotate(-5.7)" r="15.7" cy="539.19" cx="65.05" />,
|
||||
<circle key="seat-28" id="seat-28" r="15.7" cy="535.9" cx="170" />,
|
||||
<circle key="seat-29" id="seat-29" transform="rotate(-88.1)" r="15.7" cy="86.87" cx="-488.2" />,
|
||||
<circle key="seat-30" id="seat-30" r="15.7" cy="497.2" cx="125.2" />,
|
||||
<circle key="seat-31" id="seat-31" r="15.7" cy="502.8" cx="178.2" />,
|
||||
<circle key="seat-32" id="seat-32" r="15.7" cy="525.1" cx="226.3" />,
|
||||
<circle key="seat-33" id="seat-33" r="15.7" cy="533.1" cx="278.4" />,
|
||||
<circle key="seat-34" id="seat-34" r="15.7" cy="554.6" cx="327.1" />,
|
||||
<circle key="seat-35" id="seat-35" r="15.7" cy="567.9" cx="377.9" />,
|
||||
<circle key="seat-36" id="seat-36" r="15.7" cy="596.7" cx="426" />,
|
||||
<circle key="seat-37" id="seat-37" r="15.7" cy="453.8" cx="79.7" />,
|
||||
<circle key="seat-38" id="seat-38" r="15.7" cy="462" cx="135.7" />,
|
||||
<circle key="seat-39" id="seat-39" r="15.7" cy="469.3" cx="188.9" />,
|
||||
<circle key="seat-40" id="seat-40" r="15.7" cy="492.6" cx="236.4" />,
|
||||
<circle key="seat-41" id="seat-41" r="15.7" cy="500.6" cx="289.8" />,
|
||||
<circle key="seat-42" id="seat-42" r="15.7" cy="511.6" cx="341.5" />,
|
||||
<circle key="seat-43" id="seat-43" r="15.7" cy="535" cx="388.9" />,
|
||||
<circle key="seat-44" id="seat-44" r="15.7" cy="555" cx="437.3" />,
|
||||
<circle key="seat-45" id="seat-45" r="15.7" cy="419.3" cx="92.8" />,
|
||||
<circle key="seat-46" id="seat-46" r="15.7" cy="429.8" cx="148.1" />,
|
||||
<circle key="seat-47" id="seat-47" r="15.7" cy="387.4" cx="106.8" />,
|
||||
<circle key="seat-48" id="seat-48" transform="rotate(-5.7)" r="15.7" cy="364.72" cx="89.86" />,
|
||||
<circle key="seat-49" id="seat-49" r="15.7" cy="395.5" cx="164.4" />,
|
||||
<circle key="seat-50" id="seat-50" r="15.7" cy="437.3" cx="202.4" />,
|
||||
<circle key="seat-51" id="seat-51" r="15.7" cy="455.4" cx="252.1" />,
|
||||
<circle key="seat-52" id="seat-52" r="15.7" cy="325.1" cx="144.9" />,
|
||||
<circle key="seat-53" id="seat-53" r="15.7" cy="365.7" cx="181.3" />,
|
||||
<circle key="seat-54" id="seat-54" r="15.7" cy="405.1" cx="218.8" />,
|
||||
<circle key="seat-55" id="seat-55" r="15.7" cy="425.6" cx="267.7" />,
|
||||
<circle key="seat-56" id="seat-56" r="15.7" cy="464.9" cx="306.5" />,
|
||||
<circle key="seat-57" id="seat-57" r="15.7" cy="292.1" cx="168.7" />,
|
||||
<circle key="seat-58" id="seat-58" r="15.7" cy="334.6" cx="202.3" />,
|
||||
<circle key="seat-59" id="seat-59" r="15.7" cy="376.9" cx="236.7" />,
|
||||
<circle key="seat-60" id="seat-60" r="15.7" cy="265.1" cx="190.8" />,
|
||||
<circle key="seat-61" id="seat-61" r="15.7" cy="307.2" cx="224" />,
|
||||
<circle key="seat-62" id="seat-62" r="15.7" cy="346.9" cx="259.3" />,
|
||||
<circle key="seat-63" id="seat-63" r="15.7" cy="393" cx="289.6" />,
|
||||
<circle key="seat-64" id="seat-64" r="15.7" cy="435.9" cx="323.7" />,
|
||||
<circle key="seat-65" id="seat-65" r="15.7" cy="480.8" cx="357.3" />,
|
||||
<circle key="seat-66" id="seat-66" r="15.7" cy="236.2" cx="218.1" />,
|
||||
<circle key="seat-67" id="seat-67" r="15.7" cy="278.6" cx="250" />,
|
||||
<circle key="seat-68" id="seat-68" r="15.7" cy="320.2" cx="283" />,
|
||||
<circle key="seat-69" id="seat-69" r="15.7" cy="362" cx="315.5" />,
|
||||
<circle key="seat-70" id="seat-70" r="15.7" cy="403.8" cx="348.7" />,
|
||||
<circle key="seat-71" id="seat-71" r="15.7" cy="445.9" cx="381.6" />,
|
||||
<circle key="seat-72" id="seat-72" r="15.7" cy="489" cx="415.1" />,
|
||||
<circle key="seat-73" id="seat-73" r="15.7" cy="515.6" cx="460.7" />,
|
||||
<circle key="seat-74" id="seat-74" r="15.7" cy="485.2" cx="491" />,
|
||||
<circle key="seat-75" id="seat-75" r="15.7" cy="213.6" cx="243.2" />,
|
||||
<circle key="seat-76" id="seat-76" r="15.7" cy="254.9" cx="275.3" />,
|
||||
<circle key="seat-77" id="seat-77" r="15.7" cy="296.4" cx="307.8" />,
|
||||
<circle key="seat-78" id="seat-78" r="15.7" cy="337.6" cx="339.9" />,
|
||||
<circle key="seat-79" id="seat-79" r="15.7" cy="379" cx="372.5" />,
|
||||
<circle key="seat-80" id="seat-80" r="15.7" cy="420.8" cx="405.1" />,
|
||||
<circle key="seat-81" id="seat-81" r="15.7" cy="462.7" cx="437.2" />,
|
||||
<circle key="seat-82" id="seat-82" r="15.5" cy="181.8" cx="283.1" />,
|
||||
<circle key="seat-83" id="seat-83" r="15.5" cy="223.6" cx="315.4" />,
|
||||
<circle key="seat-84" id="seat-84" r="15.7" cy="262.6" cx="351" />,
|
||||
<circle key="seat-85" id="seat-85" r="15.5" cy="304.5" cx="382.7" />,
|
||||
<circle key="seat-86" id="seat-86" r="15.7" cy="339.1" cx="425.3" />,
|
||||
<circle key="seat-87" id="seat-87" r="15.7" cy="379" cx="461" />,
|
||||
<circle key="seat-88" id="seat-88" r="15.7" cy="420.4" cx="495.9" />,
|
||||
<circle key="seat-89" id="seat-89" r="15.7" cy="463.5" cx="528.1" />,
|
||||
<circle key="seat-90" id="seat-90" r="15.5" cy="160.4" cx="315.7" />,
|
||||
<circle key="seat-91" id="seat-91" r="15.5" cy="206.2" cx="342.9" />,
|
||||
<circle key="seat-92" id="seat-92" r="15.7" cy="245.1" cx="379" />,
|
||||
<circle key="seat-93" id="seat-93" r="15.5" cy="287.4" cx="410.5" />,
|
||||
<circle key="seat-94" id="seat-94" r="15.7" cy="323.4" cx="455.9" />,
|
||||
<circle key="seat-95" id="seat-95" transform="rotate(-80.8)" r="15.7" cy="555.93" cx="-274.27" />,
|
||||
<circle key="seat-96" id="seat-96" r="15.7" cy="407.6" cx="527.7" />,
|
||||
<circle key="seat-97" id="seat-97" r="15.5" cy="142.7" cx="345.9" />,
|
||||
<circle key="seat-98" id="seat-98" r="15.5" cy="186.8" cx="375.8" />,
|
||||
<circle key="seat-99" id="seat-99" r="15.5" cy="125.9" cx="377.8" />,
|
||||
<circle key="seat-100" id="seat-100" r="15.5" cy="173.7" cx="405.1" />,
|
||||
<circle key="seat-101" id="seat-101" r="15.7" cy="223" cx="422.9" />,
|
||||
<circle key="seat-102" id="seat-102" r="15.5" cy="270.9" cx="444.3" />,
|
||||
<circle key="seat-103" id="seat-103" r="15.5" cy="112" cx="409.4" />,
|
||||
<circle key="seat-104" id="seat-104" r="15.5" cy="157.7" cx="438.1" />,
|
||||
<circle key="seat-105" id="seat-105" r="15.7" cy="209" cx="453.9" />,
|
||||
<circle key="seat-106" id="seat-106" r="15.5" cy="259.6" cx="474.2" />,
|
||||
<circle key="seat-107" id="seat-107" r="15.7" cy="306.3" cx="499.3" />,
|
||||
<circle key="seat-108" id="seat-108" r="15.5" cy="100.1" cx="443.4" />,
|
||||
<circle key="seat-109" id="seat-109" r="15.5" cy="146.7" cx="472.7" />,
|
||||
<circle key="seat-110" id="seat-110" r="15.7" cy="197.9" cx="497" />,
|
||||
<circle key="seat-111" id="seat-111" r="15.5" cy="249" cx="508.8" />,
|
||||
<circle key="seat-112" id="seat-112" r="15.7" cy="298.4" cx="532.7" />,
|
||||
<circle key="seat-113" id="seat-113" r="15.7" cy="350.8" cx="538.1" />,
|
||||
<circle key="seat-114" id="seat-114" r="15.5" cy="92.2" cx="477" />,
|
||||
<circle key="seat-115" id="seat-115" r="15.5" cy="84.4" cx="510" />,
|
||||
<circle key="seat-116" id="seat-116" transform="rotate(-80.8)" r="15.5" cy="523.04" cx="-55.62" />,
|
||||
<circle key="seat-117" id="seat-117" r="15.7" cy="190.1" cx="531.6" />,
|
||||
<circle key="seat-118" id="seat-118" r="15.5" cy="243.4" cx="542.3" />,
|
||||
<circle key="seat-119" id="seat-119" r="15.5" cy="80.7" cx="544.3" />,
|
||||
<circle key="seat-120" id="seat-120" r="15.5" cy="136.1" cx="541.9" />,
|
||||
<circle key="seat-121" id="seat-121" r="15.5" cy="78.5" cx="579" />,
|
||||
<circle key="seat-122" id="seat-122" r="15.5" cy="135" cx="578.2" />,
|
||||
<circle key="seat-123" id="seat-123" r="15.7" cy="187.6" cx="577.9" />,
|
||||
<circle key="seat-124" id="seat-124" r="15.5" cy="240" cx="579" />,
|
||||
<circle key="seat-125" id="seat-125" r="15.7" cy="292.6" cx="578" />,
|
||||
<circle key="seat-126" id="seat-126" r="15.7" cy="345.3" cx="578" />,
|
||||
<circle key="seat-127" id="seat-127" r="15.7" cy="398" cx="577.8" />,
|
||||
<circle key="seat-128" id="seat-128" r="15.7" cy="451.2" cx="572.2" />,
|
||||
<circle key="seat-129" id="seat-129" r="15.5" cy="78.5" cx="613.5" />,
|
||||
<circle key="seat-130" id="seat-130" r="15.5" cy="135" cx="612.3" />,
|
||||
<circle key="seat-131" id="seat-131" r="15.7" cy="187.6" cx="612.6" />,
|
||||
<circle key="seat-132" id="seat-132" r="15.5" cy="240" cx="611.5" />,
|
||||
<circle key="seat-133" id="seat-133" r="15.7" cy="292.6" cx="612.5" />,
|
||||
<circle key="seat-134" id="seat-134" r="15.7" cy="345.3" cx="612.5" />,
|
||||
<circle key="seat-135" id="seat-135" r="15.7" cy="398" cx="612.7" />,
|
||||
<circle key="seat-136" id="seat-136" r="15.7" cy="451.2" cx="618.3" />,
|
||||
<circle key="seat-137" id="seat-137" r="15.5" cy="82.6" cx="646.3" />,
|
||||
<circle key="seat-138" id="seat-138" r="15.5" cy="86.4" cx="680.5" />,
|
||||
<circle key="seat-139" id="seat-139" r="15.5" cy="138.4" cx="650.6" />,
|
||||
<circle key="seat-140" id="seat-140" r="15.5" cy="94.2" cx="715.6" />,
|
||||
<circle key="seat-141" id="seat-141" r="15.5" cy="142.6" cx="685.4" />,
|
||||
<circle key="seat-142" id="seat-142" r="15.7" cy="190.1" cx="657" />,
|
||||
<circle key="seat-143" id="seat-143" r="15.5" cy="243.4" cx="648.3" />,
|
||||
<circle key="seat-144" id="seat-144" r="15.5" cy="104.1" cx="747.1" />,
|
||||
<circle key="seat-145" id="seat-145" r="15.5" cy="150.7" cx="719.9" />,
|
||||
<circle key="seat-146" id="seat-146" r="15.7" cy="197.9" cx="691.5" />,
|
||||
<circle key="seat-147" id="seat-147" r="15.5" cy="248.5" cx="679.8" />,
|
||||
<circle key="seat-148" id="seat-148" r="15.7" cy="298.4" cx="657.8" />,
|
||||
<circle key="seat-149" id="seat-149" r="15.7" cy="350.8" cx="652.4" />,
|
||||
<circle key="seat-150" id="seat-150" r="15.5" cy="116" cx="783.1" />,
|
||||
<circle key="seat-151" id="seat-151" r="15.5" cy="159.7" cx="750.4" />,
|
||||
<circle key="seat-152" id="seat-152" r="15.7" cy="211" cx="736.6" />,
|
||||
<circle key="seat-153" id="seat-153" r="15.5" cy="259.6" cx="716.4" />,
|
||||
<circle key="seat-154" id="seat-154" r="15.7" cy="306.3" cx="691.2" />,
|
||||
<circle key="seat-155" id="seat-155" r="15.5" cy="127.9" cx="812.8" />,
|
||||
<circle key="seat-156" id="seat-156" r="15.5" cy="173.7" cx="785.5" />,
|
||||
<circle key="seat-157" id="seat-157" r="15.7" cy="223" cx="767.7" />,
|
||||
<circle key="seat-158" id="seat-158" r="15.5" cy="270.9" cx="746.3" />,
|
||||
<circle key="seat-159" id="seat-159" r="15.5" cy="144.7" cx="846.6" />,
|
||||
<circle key="seat-160" id="seat-160" r="15.5" cy="186.8" cx="814.8" />,
|
||||
<circle key="seat-161" id="seat-161" r="15.5" cy="160.4" cx="874.8" />,
|
||||
<circle key="seat-162" id="seat-162" r="15.5" cy="206.2" cx="847.6" />,
|
||||
<circle key="seat-163" id="seat-163" r="15.7" cy="245.1" cx="811.5" />,
|
||||
<circle key="seat-164" id="seat-164" r="15.5" cy="287.4" cx="780.1" />,
|
||||
<circle key="seat-165" id="seat-165" r="15.7" cy="323.4" cx="734.6" />,
|
||||
<circle key="seat-166" id="seat-166" r="15.7" cy="357.8" cx="687.4" />,
|
||||
<circle key="seat-167" id="seat-167" r="15.7" cy="407.6" cx="662.8" />,
|
||||
<circle key="seat-168" id="seat-168" r="15.5" cy="181.8" cx="907.5" />,
|
||||
<circle key="seat-169" id="seat-169" r="15.5" cy="223.6" cx="875.2" />,
|
||||
<circle key="seat-170" id="seat-170" r="15.7" cy="262.6" cx="839.5" />,
|
||||
<circle key="seat-171" id="seat-171" r="15.5" cy="304.3" cx="807.8" />,
|
||||
<circle key="seat-172" id="seat-172" r="15.7" cy="339.1" cx="765.3" />,
|
||||
<circle key="seat-173" id="seat-173" r="15.7" cy="379" cx="729.6" />,
|
||||
<circle key="seat-174" id="seat-174" r="15.7" cy="420.4" cx="694.6" />,
|
||||
<circle key="seat-175" id="seat-175" r="15.7" cy="463.5" cx="662.5" />,
|
||||
<circle key="seat-176" id="seat-176" r="15.7" cy="485.4" cx="699.5" />,
|
||||
<circle key="seat-177" id="seat-177" r="15.7" cy="213.6" cx="947.4" />,
|
||||
<circle key="seat-178" id="seat-178" r="15.7" cy="254.9" cx="915.2" />,
|
||||
<circle key="seat-179" id="seat-179" r="15.7" cy="296.4" cx="882.7" />,
|
||||
<circle key="seat-180" id="seat-180" r="15.7" cy="337.6" cx="850.7" />,
|
||||
<circle key="seat-181" id="seat-181" r="15.7" cy="379" cx="818.1" />,
|
||||
<circle key="seat-182" id="seat-182" r="15.7" cy="420.8" cx="785.4" />,
|
||||
<circle key="seat-183" id="seat-183" r="15.7" cy="462.7" cx="753.4" />,
|
||||
<circle key="seat-184" id="seat-184" r="15.7" cy="515.4" cx="730.1" />,
|
||||
<circle key="seat-185" id="seat-185" r="15.7" cy="236.2" cx="972.4" />,
|
||||
<circle key="seat-186" id="seat-186" r="15.7" cy="278.6" cx="940.5" />,
|
||||
<circle key="seat-187" id="seat-187" r="15.7" cy="320.2" cx="907.5" />,
|
||||
<circle key="seat-188" id="seat-188" r="15.7" cy="362" cx="875.1" />,
|
||||
<circle key="seat-189" id="seat-189" r="15.7" cy="403.8" cx="841.8" />,
|
||||
<circle key="seat-190" id="seat-190" r="15.7" cy="445.9" cx="808.9" />,
|
||||
<circle key="seat-191" id="seat-191" r="15.7" cy="489" cx="775.5" />,
|
||||
<circle key="seat-192" id="seat-192" r="15.7" cy="265.1" cx="999.7" />,
|
||||
<circle key="seat-193" id="seat-193" r="15.7" cy="307.2" cx="966.6" />,
|
||||
<circle key="seat-194" id="seat-194" r="15.7" cy="346.9" cx="931.2" />,
|
||||
<circle key="seat-195" id="seat-195" r="15.7" cy="393" cx="901" />,
|
||||
<circle key="seat-196" id="seat-196" r="15.7" cy="435.9" cx="866.9" />,
|
||||
<circle key="seat-197" id="seat-197" r="15.7" cy="480.8" cx="833.2" />,
|
||||
<circle key="seat-198" id="seat-198" transform="rotate(-80.8)" r="15.7" cy="1055.16" cx="-124.85" />,
|
||||
<circle key="seat-199" id="seat-199" r="15.7" cy="334.6" cx="988.2" />,
|
||||
<circle key="seat-200" id="seat-200" r="15.7" cy="376.9" cx="953.8" />,
|
||||
<circle key="seat-201" id="seat-201" r="15.7" cy="425.6" cx="922.8" />,
|
||||
<circle key="seat-202" id="seat-202" r="15.7" cy="464.9" cx="884" />,
|
||||
<circle key="seat-203" id="seat-203" r="15.7" cy="325.1" cx="1045.7" />,
|
||||
<circle key="seat-204" id="seat-204" r="15.7" cy="365.7" cx="1009.2" />,
|
||||
<circle key="seat-205" id="seat-205" r="15.7" cy="405.1" cx="971.7" />,
|
||||
<circle key="seat-206" id="seat-206" r="15.7" cy="354.1" cx="1063.2" />,
|
||||
<circle key="seat-207" id="seat-207" transform="rotate(-80.8)" r="15.7" cy="1075.78" cx="-226.25" />,
|
||||
<circle key="seat-208" id="seat-208" r="15.7" cy="387.4" cx="1081.8" />,
|
||||
<circle key="seat-209" id="seat-209" r="15.7" cy="421.3" cx="1095.7" />,
|
||||
<circle key="seat-210" id="seat-210" r="15.7" cy="429.8" cx="1042.5" />,
|
||||
<circle key="seat-211" id="seat-211" r="15.7" cy="437.3" cx="988.2" />,
|
||||
<circle key="seat-212" id="seat-212" r="15.7" cy="455.4" cx="938.5" />,
|
||||
<circle key="seat-213" id="seat-213" r="15.7" cy="455.8" cx="1108.8" />,
|
||||
<circle key="seat-214" id="seat-214" r="15.7" cy="462" cx="1054.9" />,
|
||||
<circle key="seat-215" id="seat-215" r="15.7" cy="469.3" cx="1001.6" />,
|
||||
<circle key="seat-216" id="seat-216" r="15.7" cy="492.6" cx="954.1" />,
|
||||
<circle key="seat-217" id="seat-217" r="15.7" cy="500.6" cx="900.8" />,
|
||||
<circle key="seat-218" id="seat-218" r="15.7" cy="511.6" cx="849" />,
|
||||
<circle key="seat-219" id="seat-219" r="15.7" cy="535" cx="801.6" />,
|
||||
<circle key="seat-220" id="seat-220" r="15.7" cy="554.8" cx="753.3" />,
|
||||
<circle key="seat-221" id="seat-221" r="15.7" cy="490.9" cx="1118" />,
|
||||
<circle key="seat-222" id="seat-222" r="15.7" cy="497.2" cx="1065.3" />,
|
||||
<circle key="seat-223" id="seat-223" r="15.7" cy="502.8" cx="1012.3" />,
|
||||
<circle key="seat-224" id="seat-224" r="15.7" cy="525.1" cx="964.2" />,
|
||||
<circle key="seat-225" id="seat-225" r="15.7" cy="533.1" cx="912.2" />,
|
||||
<circle key="seat-226" id="seat-226" r="15.7" cy="554.6" cx="863.4" />,
|
||||
<circle key="seat-227" id="seat-227" r="15.7" cy="567.9" cx="812.7" />,
|
||||
<circle key="seat-228" id="seat-228" r="15.7" cy="596.7" cx="764.8" />,
|
||||
<circle key="seat-229" id="seat-229" r="15.7" cy="528.9" cx="1126.1" />,
|
||||
<circle key="seat-230" id="seat-230" r="15.7" cy="530.2" cx="1072.7" />,
|
||||
<circle key="seat-231" id="seat-231" transform="rotate(-80.8)" r="15.7" cy="1092.81" cx="-365.69" />,
|
||||
<circle key="seat-232" id="seat-232" r="15.7" cy="562.9" cx="1130.6" />,
|
||||
<circle key="seat-233" id="seat-233" r="15.7" cy="566.7" cx="1078.3" />,
|
||||
<circle key="seat-234" id="seat-234" transform="rotate(-80.8)" r="15.7" cy="1103.39" cx="-398.54" />,
|
||||
<circle key="seat-235" id="seat-235" r="15.7" cy="564.5" cx="972.4" />,
|
||||
<circle key="seat-236" id="seat-236" r="15.7" cy="568.6" cx="919.7" />,
|
||||
<circle key="seat-237" id="seat-237" r="15.7" cy="588" cx="869.4" />,
|
||||
<circle key="seat-238" id="seat-238" r="15.7" cy="602.5" cx="1133.5" />,
|
||||
<circle key="seat-239" id="seat-239" r="15.7" cy="600.9" cx="1081" />,
|
||||
<circle key="seat-240" id="seat-240" transform="rotate(-80.8)" r="15.7" cy="1111.41" cx="-431.3" />,
|
||||
<circle key="seat-241" id="seat-241" r="15.7" cy="598.6" cx="975.6" />,
|
||||
<circle key="seat-242" id="seat-242" r="15.7" cy="602.6" cx="923.4" />,
|
||||
<circle key="seat-243" id="seat-243" r="15.7" cy="636.4" cx="1133.9" />,
|
||||
<circle key="seat-244" id="seat-244" r="15.7" cy="639" cx="1081.3" />,
|
||||
<circle key="seat-245" id="seat-245" transform="rotate(-80.8)" r="15.7" cy="1117.48" cx="-466.13" />,
|
||||
<circle key="seat-246" id="seat-246" r="15.7" cy="639.2" cx="976.3" />,
|
||||
<circle key="seat-247" id="seat-247" r="15.7" cy="638.8" cx="923.9" />,
|
||||
<circle key="seat-248" id="seat-248" r="15.7" cy="635.1" cx="871.2" />,
|
||||
<circle key="seat-249" id="seat-249" r="15.7" cy="625.7" cx="818.8" />,
|
||||
<circle key="seat-250" id="seat-250" r="15.7" cy="639" cx="766.3" />,
|
||||
<circle key="seat-251" id="seat-251" r="15.7" cy="673.1" cx="1081.3" />,
|
||||
<circle key="seat-252" id="seat-252" transform="rotate(-80.8)" r="15.7" cy="1122.99" cx="-499.74" />,
|
||||
<circle key="seat-253" id="seat-253" r="15.7" cy="673.5" cx="976.3" />,
|
||||
<circle key="seat-254" id="seat-254" r="15.7" cy="673.2" cx="924" />,
|
||||
<circle key="seat-255" id="seat-255" r="15.7" cy="669.5" cx="871.2" />,
|
||||
<circle key="seat-256" id="seat-256" r="15.7" cy="660" cx="819.7" />,
|
||||
];
|
||||
|
||||
let seatIndex = 1; // Empezamos a contar desde 1, ya que el 0 es presidencial
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 1190.6 772.2" width={size} height={size * (772.2 / 1190.6)} style={{ display: 'block', margin: 'auto' }}>
|
||||
<g>
|
||||
{/* Renderizamos el escaño presidencial primero y por separado */}
|
||||
{presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], {
|
||||
fill: presidenteBancada.color || '#A9A9A9',
|
||||
strokeWidth: 0.5,
|
||||
})}
|
||||
{partyData.map(partido => {
|
||||
// Por cada partido, creamos un array combinado de sus escaños
|
||||
const partySeats = [
|
||||
...Array(partido.bancasFijos).fill({ isNew: false }),
|
||||
...Array(partido.bancasGanadas).fill({ isNew: true })
|
||||
];
|
||||
|
||||
return (
|
||||
// Envolvemos todos los escaños de un partido en un <g>
|
||||
<g
|
||||
key={partido.id}
|
||||
className="party-block"
|
||||
data-tooltip-id="party-tooltip"
|
||||
data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`}
|
||||
>
|
||||
{partySeats.map((seatInfo, i) => {
|
||||
// Si ya no hay más plantillas de escaños, no renderizamos nada
|
||||
if (seatIndex >= seatElements.length) return null;
|
||||
|
||||
const template = seatElements[seatIndex];
|
||||
seatIndex++; // Incrementamos el contador para el siguiente escaño
|
||||
|
||||
// Clonamos la plantilla con el estilo apropiado
|
||||
return React.cloneElement(template, {
|
||||
key: `${partido.id}-${i}`,
|
||||
className: 'seat-circle',
|
||||
fill: partido.color || '#808080',
|
||||
fillOpacity: seatInfo.isNew ? 1 : 0.3, // Opacidad para bancas previas
|
||||
stroke: partido.color || '#808080',
|
||||
strokeWidth: 0.5,
|
||||
});
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Renderizamos los escaños vacíos sobrantes */}
|
||||
{seatIndex < seatElements.length &&
|
||||
seatElements.slice(seatIndex).map((template, i) =>
|
||||
React.cloneElement(template, {
|
||||
key: `empty-${i}`,
|
||||
fill: '#E0E0E0',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 0.5
|
||||
})
|
||||
)
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/ImageWithFallback.tsx
|
||||
// src/components/common/ImageWithFallback.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/components/ParliamentLayout.tsx
|
||||
// src/components/common/ParliamentLayout.tsx
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { assetBaseUrl } from '../apiService';
|
||||
import { assetBaseUrl } from '../../apiService';
|
||||
import { handleImageFallback } from './imageFallback';
|
||||
|
||||
// Interfaces (no cambian)
|
||||
@@ -0,0 +1,154 @@
|
||||
// src/components/common/SenadoresNacionalesLayout.tsx
|
||||
import React from 'react';
|
||||
import type { PartidoComposicionNacional } from '../../apiService';
|
||||
|
||||
// Interfaces
|
||||
interface SenadoresNacionalesLayoutProps {
|
||||
partyData: PartidoComposicionNacional[];
|
||||
size?: number;
|
||||
presidenteBancada?: { color: string | null } | null;
|
||||
}
|
||||
|
||||
const PRESIDENTE_SEAT_INDEX = 0;
|
||||
|
||||
export const SenadoresNacionalesLayout: React.FC<SenadoresNacionalesLayoutProps> = ({
|
||||
partyData,
|
||||
size = 800,
|
||||
presidenteBancada,
|
||||
}) => {
|
||||
// --- ARRAY DE 73 ELEMENTOS <circle> ORDENADOS POR ID DE "seat-X" ---
|
||||
// El asiento 0 es el presidencial, los 72 restantes son los senadores.
|
||||
const seatElements = [
|
||||
<circle key="seat-0" id="seat-0" r="7.1" cy="187" cx="168.6" />,
|
||||
<circle key="seat-1" id="seat-1" r="7.1" cy="166" cx="21.8" />,
|
||||
<circle key="seat-2" id="seat-2" r="7.1" cy="172" cx="51.5" />,
|
||||
<circle key="seat-3" id="seat-3" r="7.1" cy="174.5" cx="82.7" />,
|
||||
<circle key="seat-4" id="seat-4" r="7.1" cy="147.4" cx="21.5" />,
|
||||
<circle key="seat-5" id="seat-5" r="7.1" cy="155.2" cx="51.8" />,
|
||||
<circle key="seat-6" id="seat-6" r="7.1" cy="156.3" cx="83.4" />,
|
||||
<circle key="seat-7" id="seat-7" r="7.1" cy="169.9" cx="120.9" />,
|
||||
<circle key="seat-8" id="seat-8" r="7.1" cy="128.4" cx="22.8" />,
|
||||
<circle key="seat-9" id="seat-9" r="7.1" cy="137.9" cx="53.2" />,
|
||||
<circle key="seat-10" id="seat-10" r="7.1" cy="138.8" cx="85.5" />,
|
||||
<circle key="seat-11" id="seat-11" r="7.1" cy="151.9" cx="120.9" />,
|
||||
<circle key="seat-12" id="seat-12" r="7.1" cy="109" cx="25.6" />,
|
||||
<circle key="seat-13" id="seat-13" r="7.1" cy="121.3" cx="57.2" />,
|
||||
<circle key="seat-14" id="seat-14" r="7.1" cy="91.5" cx="34.2" />,
|
||||
<circle key="seat-15" id="seat-15" r="7.1" cy="105.7" cx="64.8" />,
|
||||
<circle key="seat-16" id="seat-16" r="7.1" cy="122.5" cx="92.9" />,
|
||||
<circle key="seat-17" id="seat-17" r="7.1" cy="136.2" cx="128.2" />,
|
||||
<circle key="seat-18" id="seat-18" r="7.1" cy="75.5" cx="45.3" />,
|
||||
<circle key="seat-19" id="seat-19" r="7.1" cy="91.3" cx="75.7" />,
|
||||
<circle key="seat-20" id="seat-20" r="7.1" cy="106.5" cx="106.3" />,
|
||||
<circle key="seat-21" id="seat-21" r="7.1" cy="59.8" cx="57.9" />,
|
||||
<circle key="seat-22" id="seat-22" r="7.1" cy="78.6" cx="89.5" />,
|
||||
<circle key="seat-23" id="seat-23" r="7.1" cy="45.3" cx="73.2" />,
|
||||
<circle key="seat-24" id="seat-24" r="7.1" cy="67.2" cx="104.6" />,
|
||||
<circle key="seat-25" id="seat-25" r="7.1" cy="94.3" cx="121.6" />,
|
||||
<circle key="seat-26" id="seat-26" r="7.1" cy="124.3" cx="141.1" />,
|
||||
<circle key="seat-27" id="seat-27" r="7.1" cy="32.7" cx="90.8" />,
|
||||
<circle key="seat-28" id="seat-28" r="7.1" cy="58.3" cx="120.9" />,
|
||||
<circle key="seat-29" id="seat-29" r="7.1" cy="84.9" cx="139.1" />,
|
||||
<circle key="seat-30" id="seat-30" r="7.1" cy="116.4" cx="157.2" />,
|
||||
<circle key="seat-31" id="seat-31" r="7.1" cy="24.6" cx="109.5" />,
|
||||
<circle key="seat-32" id="seat-32" r="7.1" cy="52.2" cx="138.6" />,
|
||||
<circle key="seat-33" id="seat-33" r="7.1" cy="79.5" cx="157.8" />,
|
||||
<circle key="seat-34" id="seat-34" r="7.1" cy="17.9" cx="128.8" />,
|
||||
<circle key="seat-35" id="seat-35" r="7.1" cy="15.2" cx="147.7" />,
|
||||
<circle key="seat-36" id="seat-36" r="7.1" cy="48.3" cx="156.9" />,
|
||||
<circle key="seat-37" id="seat-37" r="7.1" cy="15.2" cx="192.5" />,
|
||||
<circle key="seat-38" id="seat-38" r="7.1" cy="48.3" cx="183.3" />,
|
||||
<circle key="seat-39" id="seat-39" r="7.1" cy="79.5" cx="182.4" />,
|
||||
<circle key="seat-40" id="seat-40" r="7.1" cy="115.8" cx="182.2" />,
|
||||
<circle key="seat-41" id="seat-41" r="7.1" cy="17.9" cx="211.4" />,
|
||||
<circle key="seat-42" id="seat-42" r="7.1" cy="52.2" cx="201.6" />,
|
||||
<circle key="seat-43" id="seat-43" r="7.1" cy="24.6" cx="230.7" />,
|
||||
<circle key="seat-44" id="seat-44" r="7.1" cy="58.3" cx="219.3" />,
|
||||
<circle key="seat-45" id="seat-45" r="7.1" cy="84.9" cx="201.1" />,
|
||||
<circle key="seat-46" id="seat-46" r="7.1" cy="32.7" cx="249.4" />,
|
||||
<circle key="seat-47" id="seat-47" r="7.1" cy="67.2" cx="235.6" />,
|
||||
<circle key="seat-48" id="seat-48" r="7.1" cy="94.3" cx="218.6" />,
|
||||
<circle key="seat-49" id="seat-49" r="7.1" cy="124.3" cx="199.1" />,
|
||||
<circle key="seat-50" id="seat-50" r="7.1" cy="45.3" cx="267" />,
|
||||
<circle key="seat-51" id="seat-51" r="7.1" cy="59.8" cx="282.3" />,
|
||||
<circle key="seat-52" id="seat-52" r="7.1" cy="78.6" cx="250.7" />,
|
||||
<circle key="seat-53" id="seat-53" r="7.1" cy="106.5" cx="234" />,
|
||||
<circle key="seat-54" id="seat-54" r="7.1" cy="136.2" cx="212" />,
|
||||
<circle key="seat-55" id="seat-55" r="7.1" cy="75.5" cx="294.9" />,
|
||||
<circle key="seat-56" id="seat-56" r="7.1" cy="91.3" cx="264.5" />,
|
||||
<circle key="seat-57" id="seat-57" r="7.1" cy="91.5" cx="306" />,
|
||||
<circle key="seat-58" id="seat-58" r="7.1" cy="105.7" cx="275.4" />,
|
||||
<circle key="seat-59" id="seat-59" r="7.1" cy="122.5" cx="247.3" />,
|
||||
<circle key="seat-60" id="seat-60" r="7.1" cy="109" cx="313.5" />,
|
||||
<circle key="seat-61" id="seat-61" r="7.1" cy="121.3" cx="283" />,
|
||||
<circle key="seat-62" id="seat-62" r="7.1" cy="138.8" cx="254.7" />,
|
||||
<circle key="seat-63" id="seat-63" r="7.1" cy="151.9" cx="219.3" />,
|
||||
<circle key="seat-64" id="seat-64" r="7.1" cy="128.4" cx="317.4" />,
|
||||
<circle key="seat-65" id="seat-65" r="7.1" cy="137.9" cx="287" />,
|
||||
<circle key="seat-66" id="seat-66" r="7.1" cy="156.3" cx="256.8" />,
|
||||
<circle key="seat-67" id="seat-67" r="7.1" cy="169.9" cx="219.3" />,
|
||||
<circle key="seat-68" id="seat-68" r="7.1" cy="147.4" cx="318.7" />,
|
||||
<circle key="seat-69" id="seat-69" r="7.1" cy="155.2" cx="288.4" />,
|
||||
<circle key="seat-70" id="seat-70" r="7.1" cy="166" cx="318.4" />,
|
||||
<circle key="seat-71" id="seat-71" r="7.1" cy="172" cx="288.7" />,
|
||||
<circle key="seat-72" id="seat-72" r="7.1" cy="174.5" cx="257.5" />,
|
||||
];
|
||||
|
||||
let seatIndex = 1; // Empezamos desde 1 porque el 0 es para el presidente
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 340.2 220.5" width={size} height={size * (220.5 / 340.2)} style={{ display: 'block', margin: 'auto' }}>
|
||||
<g>
|
||||
{/* Renderizamos primero el escaño del presidente por separado */}
|
||||
{presidenteBancada && React.cloneElement(seatElements[PRESIDENTE_SEAT_INDEX], {
|
||||
fill: presidenteBancada.color || '#A9A9A9',
|
||||
strokeWidth: 0.5,
|
||||
})}
|
||||
|
||||
{/* Mapeamos los partidos para crear los bloques */}
|
||||
{partyData.map(partido => {
|
||||
const partySeats = [
|
||||
...Array(partido.bancasFijos).fill({ isNew: false }),
|
||||
...Array(partido.bancasGanadas).fill({ isNew: true })
|
||||
];
|
||||
|
||||
return (
|
||||
<g
|
||||
key={partido.id}
|
||||
className="party-block"
|
||||
data-tooltip-id="party-tooltip"
|
||||
data-tooltip-content={`${partido.nombreCorto || partido.nombre}: ${partido.bancasTotales} bancas`}
|
||||
>
|
||||
{partySeats.map((seatInfo, i) => {
|
||||
if (seatIndex >= seatElements.length) return null;
|
||||
|
||||
const template = seatElements[seatIndex];
|
||||
seatIndex++;
|
||||
|
||||
return React.cloneElement(template, {
|
||||
key: `${partido.id}-${i}`,
|
||||
className: 'seat-circle',
|
||||
fill: partido.color || '#808080',
|
||||
fillOpacity: seatInfo.isNew ? 1 : 0.3,
|
||||
stroke: partido.color || '#808080',
|
||||
strokeWidth: 0.5,
|
||||
});
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* Renderizamos escaños vacíos si sobran */}
|
||||
{seatIndex < seatElements.length &&
|
||||
seatElements.slice(seatIndex).map((template, i) =>
|
||||
React.cloneElement(template, {
|
||||
key: `empty-${i}`,
|
||||
fill: '#E0E0E0',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 0.5
|
||||
})
|
||||
)
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/components/SenateLayout.tsx
|
||||
// src/components/common/SenateLayout.tsx
|
||||
import React, { useLayoutEffect } from 'react';
|
||||
import { handleImageFallback } from './imageFallback';
|
||||
import { assetBaseUrl } from '../apiService';
|
||||
import { assetBaseUrl } from '../../apiService';
|
||||
|
||||
// Interfaces
|
||||
interface SeatFillData {
|
||||
@@ -1,4 +1,4 @@
|
||||
// src/components/imageFallback.ts
|
||||
// src/components/common/imageFallback.ts
|
||||
|
||||
export function handleImageFallback(selector: string, fallbackImageUrl: string) {
|
||||
// Le decimos a TypeScript que el resultado será una lista de elementos de imagen HTML
|
||||
@@ -0,0 +1,215 @@
|
||||
// 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'
|
||||
import { HomeCarouselNacionalWidget } from './nacionales/HomeCarouselNacionalWidget';
|
||||
import { TablaConurbanoWidget } from './nacionales/TablaConurbanoWidget';
|
||||
import { TablaSeccionesWidget } from './nacionales/TablaSeccionesWidget';
|
||||
import { ResumenNacionalWidget } from './nacionales/ResumenNacionalWidget';
|
||||
import { HomeCarouselProvincialWidget } from './nacionales/HomeCarouselProvincialWidget';
|
||||
|
||||
// --- NUEVO COMPONENTE REUTILIZABLE PARA CONTENIDO COLAPSABLE ---
|
||||
const CollapsibleWidgetWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="collapsible-container">
|
||||
<div className={`collapsible-content ${isExpanded ? 'expanded' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
<button className="toggle-button" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
{isExpanded ? 'Mostrar Menos' : 'Mostrar Más'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DevAppLegislativas = () => {
|
||||
// Estilos para los separadores y descripciones para mejorar la legibilidad
|
||||
const sectionStyle = {
|
||||
border: '2px solid #007bff',
|
||||
borderRadius: '8px',
|
||||
padding: '2px',
|
||||
marginTop: '3rem',
|
||||
marginBottom: '3rem',
|
||||
backgroundColor: '#f8f9fa'
|
||||
};
|
||||
const descriptionStyle = {
|
||||
fontFamily: 'sans-serif',
|
||||
color: '#333',
|
||||
lineHeight: 1.6
|
||||
};
|
||||
const codeStyle = {
|
||||
backgroundColor: '#e9ecef',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'Roboto'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-legislativas2025">
|
||||
<h1>Visor de Widgets</h1>
|
||||
|
||||
<CongresoNacionalWidget eleccionId={2} />
|
||||
<PanelNacionalWidget eleccionId={2} />
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel de Resultados Provincias (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={3} titulo="Diputados - Provincia de Buenos Aires" mapLinkUrl={''} /></code>
|
||||
</p>
|
||||
<HomeCarouselWidget
|
||||
eleccionId={2} // Nacional
|
||||
distritoId="02" // Buenos Aires
|
||||
categoriaId={3} // Diputados Nacionales
|
||||
titulo="Diputados - Provincia de Buenos Aires"
|
||||
mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel de Resultados Nación (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselNacionalWidget eleccionId={2} categoriaId={3} titulo="Diputados - Total País" mapLinkUrl={''} /></code>
|
||||
</p>
|
||||
<HomeCarouselNacionalWidget
|
||||
eleccionId={2}
|
||||
categoriaId={3} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Diputados - Total País"
|
||||
mapLinkUrl="https://www.eldia.com/nota/2025-10-23-14-53-0-mapa-con-los-resultados-en-tiempo-real-servicios"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel de Resultados Nación (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselNacionalWidget eleccionId={2} categoriaId={2} titulo="Senadores - Total País" /></code>
|
||||
</p>
|
||||
<HomeCarouselNacionalWidget
|
||||
eleccionId={2}
|
||||
categoriaId={2} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Senadores - Total País" mapLinkUrl={''} />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel Provincial con Selector (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Categoría Diputados
|
||||
</p>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={3} titulo="Diputados" /></code>
|
||||
</p>
|
||||
<HomeCarouselProvincialWidget
|
||||
eleccionId={2}
|
||||
categoriaId={3} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Diputados"
|
||||
/>
|
||||
|
||||
<p style={descriptionStyle}>
|
||||
Categoría Senadores
|
||||
</p>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselProvincialWidget eleccionId={2} categoriaId={2} titulo="Senadores" /></code>
|
||||
</p>
|
||||
<HomeCarouselProvincialWidget
|
||||
eleccionId={2}
|
||||
categoriaId={2} // 3 para Diputados, 2 para Senadores
|
||||
titulo="Senadores"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- SECCIÓN PARA EL WIDGET DE TARJETAS CON EJEMPLOS --- */}
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Resultados por Provincia (Tarjetas)</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>1. Vista por Defecto</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Sin parámetros adicionales. Muestra todas las provincias, con sus categorías correspondientes (Diputados para las 24, Senadores para las 8 que renuevan). Muestra los 2 principales partidos por defecto.
|
||||
<br />
|
||||
Uso: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} /></code>
|
||||
</p>
|
||||
<CollapsibleWidgetWrapper>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} />
|
||||
</CollapsibleWidgetWrapper>
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>2. Filtrado por Provincia (focoDistritoId)</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Muestra únicamente la tarjeta de una provincia específica. Ideal para páginas de noticias locales. El ID de distrito ("02" para Bs. As., "06" para Chaco) se pasa como prop.
|
||||
<br />
|
||||
Ejemplo Buenos Aires: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="02" />
|
||||
|
||||
<p style={{ ...descriptionStyle, marginTop: '2rem' }}>
|
||||
Ejemplo Chaco (que también renueva Senadores): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="06" />
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>3. Filtrado por Categoría (focoCategoriaId)</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Muestra todas las provincias que votan para una categoría específica.
|
||||
<br />
|
||||
Ejemplo Senadores (ID 2): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} />
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>4. Indicando Cantidad de Resultados (cantidadResultados)</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Controla cuántos partidos se muestran en cada categoría. Por defecto son 2.
|
||||
<br />
|
||||
Ejemplo mostrando el TOP 3 de cada categoría: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} /></code>
|
||||
</p>
|
||||
<CollapsibleWidgetWrapper>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} cantidadResultados={3} />
|
||||
</CollapsibleWidgetWrapper>
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>5. Mostrando las Bancas (mostrarBancas)</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Útil para contextos donde importan las bancas. La prop <code style={codeStyle}>mostrarBancas</code> se establece en <code style={codeStyle}>true</code>.
|
||||
<br />
|
||||
Ejemplo en Tierra del Fuego: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="23" mostrarBancas={true} />
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>6. Combinación de Parámetros</h3>
|
||||
<p style={descriptionStyle}>
|
||||
Se pueden combinar todos los parámetros para vistas muy específicas.
|
||||
<br />
|
||||
Ejemplo: Mostrar el TOP 1 (el ganador) para la categoría de SENADORES en la provincia de RÍO NEGRO (Distrito ID "16").
|
||||
<br />
|
||||
Uso: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} />
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Tabla de Resultados del Conurbano</h2>
|
||||
<TablaConurbanoWidget />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Tabla de Resultados por Sección Electoral</h2>
|
||||
<TablaSeccionesWidget />
|
||||
</div>
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Resumen Nacional de Resultados por Provincia</h2>
|
||||
<ResumenNacionalWidget />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
.container-legislativas2025{
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/* src/features/legislativas/nacionales/CongresoNacionalWidget.module.css */
|
||||
|
||||
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
|
||||
.congresoContainer,
|
||||
.congresoContainer * {
|
||||
font-family: "Roboto", system-ui, sans-serif !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Reseteos Generales --- */
|
||||
.congresoContainer h1, .congresoContainer h2, .congresoContainer h3, .congresoContainer h4, .congresoContainer h5, .congresoContainer h6, .congresoContainer div, .congresoContainer p, .congresoContainer strong, .congresoContainer em, .congresoContainer b, .congresoContainer i {
|
||||
line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline; border: 0;
|
||||
}
|
||||
|
||||
.congresoContainer span{
|
||||
line-height: 1.2; margin: 0; padding: 0; color: inherit; text-align: left; vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* --- ESTILOS BASE (VISTA ANCHA/ESCRITORIO) --- */
|
||||
.congresoContainer {
|
||||
display: flex; flex-direction: row; align-items: stretch; gap: 1.5rem;
|
||||
background-color: #ffffff; border: 1px solid #e0e0e0; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem; border-radius: 8px; max-width: 900px; margin: 20px auto;
|
||||
color: #333333; --primary-accent-color: #007bff; height: 500px;
|
||||
container-type: inline-size; container-name: congreso-widget;
|
||||
}
|
||||
|
||||
.congresoGrafico { flex: 2; min-width: 300px; display: flex; flex-direction: column; }
|
||||
.congresoHemicicloWrapper { flex-grow: 1; display: flex; align-items: center; justify-content: center; width: 100%; }
|
||||
.congresoHemicicloWrapper.isHovering :global(.party-block:not(:hover)) { opacity: 0.3; }
|
||||
.congresoGrafico svg { width: 100%; height: auto; }
|
||||
.congresoFooter { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 0.5rem 0 0.5rem; margin-top: auto; font-size: 0.8em; color: #666; border-top: 1px solid #eee; }
|
||||
.footerLegend { display: flex; gap: 1.25rem; align-items: center; }
|
||||
.footerLegendItem { display: flex; align-items: center; gap: 0.6rem; font-size: 1.1em; }
|
||||
.legendIcon { display: inline-block; width: 14px; height: 14px; border-radius: 50%; }
|
||||
.legendIconSolid { background-color: #888; }
|
||||
.legendIconRing {
|
||||
background-color: rgba(136, 136, 136, 0.3);
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
.footerTimestamp { font-weight: 500; font-size: 0.75em; text-align: right; }
|
||||
.congresoSummary { flex: 1; border-left: 1px solid #e0e0e0; padding-left: 1.25rem; display: flex; flex-direction: column; justify-content: flex-start; }
|
||||
|
||||
.summaryHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center; /* Centra el título y la barra de pestañas */
|
||||
gap: 0.75rem; /* Espacio vertical entre el título y las pestañas */
|
||||
margin-bottom: 1rem; /* Espacio entre la cabecera y el resto del contenido */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.congresoSummary h3 {
|
||||
text-align: center;
|
||||
margin: 0; /* Quitamos el margen para que el 'gap' del header lo controle */
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.chamberTabs {
|
||||
display: flex;
|
||||
margin-bottom: 0; /* Quitamos el margen para que el 'gap' del header lo controle */
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
width: 100%; /* Hacemos que la barra de pestañas ocupe todo el ancho del header */
|
||||
}
|
||||
|
||||
.chamberTabs button { flex: 1; padding: 0.5rem; border: none; background-color: #f8f9fa; color: #6c757d; font-family: inherit; font-size: 1em; font-weight: 500; cursor: pointer; transition: all 0.2s ease-in-out; text-align: center; }
|
||||
.chamberTabs button:first-child { border-right: 1px solid #dee2e6; }
|
||||
.chamberTabs button:hover { background-color: #e9ecef; }
|
||||
.chamberTabs button.active { background-color: var(--primary-accent-color); color: #ffffff; }
|
||||
.summaryMetric { display: flex; justify-content: space-between; align-items: baseline; margin-top: 0.25rem; margin-bottom: 0.25rem; font-size: 1.1em; }
|
||||
.summaryMetric strong { font-size: 1.25em; font-weight: 700; color: var(--primary-accent-color); }
|
||||
.congresoSummary hr { border: none; border-top: 1px solid #e0e0e0; margin: 1rem 0; }
|
||||
.partidoListaContainer { flex-grow: 1; overflow-y: auto; min-height: 0; }
|
||||
.partidoLista { list-style: none; padding: 0; margin: 0; padding-right: 8px; }
|
||||
.partidoLista li { display: flex; align-items: center; margin-bottom: 0.85rem; }
|
||||
.partidoColorBox { width: 16px; height: 16px; border-radius: 4px; margin-right: 12px; flex-shrink: 0; }
|
||||
.partidoNombre { flex-grow: 1; font-size: 1em; }
|
||||
.partidoBancas { font-weight: 700; font-size: 1.1em; }
|
||||
|
||||
|
||||
/* --- REGLA #1: RESPONSIVIDAD EXTERNA Y LAYOUT PRINCIPAL (MÓVIL) --- */
|
||||
@media (max-width: 768px) {
|
||||
.congresoContainer {
|
||||
/* Forzar el comportamiento externo */
|
||||
width: 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
grid-column: 1 / -1 !important;
|
||||
max-width: none !important;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
flex-direction: column !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- REGLA #2: AJUSTES FINOS INTERNOS CUANDO EL WIDGET ES ESTRECHO --- */
|
||||
@container congreso-widget (max-width: 700px) {
|
||||
/* La dirección del flex ya fue establecida por la @media query. */
|
||||
/* Aquí solo hacemos los ajustes de contenido. */
|
||||
|
||||
.congresoGrafico { min-width: 0; }
|
||||
.congresoSummary { border-left: none; padding-left: 0; border-top: 1px solid #e0e0e0; padding-top: 1rem; margin-top: 1rem; }
|
||||
.congresoSummary h3 { font-size: 1.25em; }
|
||||
.summaryMetric { font-size: 1em; }
|
||||
.summaryMetric strong { font-size: 1.3em; }
|
||||
.partidoNombre { font-size: 0.9em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.partidoBancas { font-size: 1em; }
|
||||
.partidoListaContainer { overflow-y: visible; max-height: none; }
|
||||
.footerLegend { gap: 1rem; }
|
||||
.footerLegendItem{ font-size: 0.9em; }
|
||||
.congresoFooter { flex-direction: column; align-items: center; gap: 0.75rem; padding: 0.75rem 0rem; }
|
||||
}
|
||||
|
||||
.partidoListaContainer {
|
||||
scrollbar-width: thin; /* Hace el scrollbar más delgado */
|
||||
scrollbar-color: #c1c1c1 #f1f1f1; /* Color del thumb y del track */
|
||||
}
|
||||
.partidoListaContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Estilo del "track" o canal por donde se mueve el scrollbar */
|
||||
.partidoListaContainer::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Estilo del "thumb" o la barra que se arrastra */
|
||||
.partidoListaContainer::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 10px;
|
||||
border: 2px solid #f1f1f1;
|
||||
}
|
||||
|
||||
/* Estilo del "thumb" al pasar el mouse por encima */
|
||||
.partidoListaContainer::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* --- A. Tooltip de FOTO DE LEGISLADOR (seat-tooltip) --- */
|
||||
|
||||
:global(#seat-tooltip.react-tooltip) {
|
||||
opacity: 1 !important;
|
||||
background-color: #ffffff !important;
|
||||
border-radius: 6px !important;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2) !important;
|
||||
padding: 0 !important;
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
:global(.seat-tooltip) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
:global(.seat-tooltip img) {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #ccc;
|
||||
}
|
||||
|
||||
:global(.seat-tooltip p) {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
font-family: "Roboto", system-ui, sans-serif !important;
|
||||
color: #333333 !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* --- B. ¡NUEVO! Tooltip de BLOQUE DE PARTIDO (party-tooltip) --- */
|
||||
|
||||
:global(#party-tooltip.react-tooltip) {
|
||||
opacity: 1 !important;
|
||||
background-color: #333333 !important; /* Fondo oscuro, como el nativo */
|
||||
border-radius: 4px !important;
|
||||
padding: 4px 8px !important; /* Padding interno */
|
||||
z-index: 9998 !important; /* Ligeramente por debajo del otro por si acaso */
|
||||
pointer-events: none; /* Evita que el tooltip interfiera con el mouse */
|
||||
}
|
||||
|
||||
/* Usamos la clase que añadimos en el TSX para estilizar el contenido */
|
||||
.partyTooltipContainer {
|
||||
font-size: 13px !important;
|
||||
font-family: "Roboto", system-ui, sans-serif !important;
|
||||
color: #ffffff !important; /* Letras blancas para contrastar con el fondo oscuro */
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// src/features/legislativas/nacionales/CongresoNacionalWidget.tsx
|
||||
import { useState, Suspense, useMemo } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { DiputadosNacionalesLayout } from '../../../components/common/DiputadosNacionalesLayout';
|
||||
import { SenadoresNacionalesLayout } from '../../../components/common/SenadoresNacionalesLayout';
|
||||
import { getComposicionNacional, type ComposicionNacionalData, type PartidoComposicionNacional } from '../../../apiService';
|
||||
import styles from './CongresoNacionalWidget.module.css';
|
||||
|
||||
interface CongresoNacionalWidgetProps {
|
||||
eleccionId: number;
|
||||
}
|
||||
|
||||
const formatTimestamp = (dateString: string) => {
|
||||
if (!dateString) return '...';
|
||||
const date = new Date(dateString);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
const [camaraActiva, setCamaraActiva] = useState<'diputados' | 'senadores'>('diputados');
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const { data } = useSuspenseQuery<ComposicionNacionalData>({
|
||||
queryKey: ['composicionNacional', eleccionId],
|
||||
queryFn: () => getComposicionNacional(eleccionId),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const datosCamaraActual = data[camaraActiva];
|
||||
|
||||
const partidosOrdenados = useMemo(() => {
|
||||
if (!datosCamaraActual?.partidos) return [];
|
||||
const partidosACopiar = [...datosCamaraActual.partidos];
|
||||
partidosACopiar.sort((a, b) => {
|
||||
const ordenA = camaraActiva === 'diputados' ? a.ordenDiputadosNacionales : a.ordenSenadoresNacionales;
|
||||
const ordenB = camaraActiva === 'diputados' ? b.ordenDiputadosNacionales : b.ordenSenadoresNacionales;
|
||||
return (ordenA ?? 999) - (ordenB ?? 999);
|
||||
});
|
||||
return partidosACopiar;
|
||||
}, [datosCamaraActual, camaraActiva]);
|
||||
|
||||
const partyDataParaLayout = useMemo(() => {
|
||||
if (camaraActiva === 'senadores') return partidosOrdenados;
|
||||
if (!partidosOrdenados || !datosCamaraActual.presidenteBancada?.color) return partidosOrdenados;
|
||||
const partidoPresidente = partidosOrdenados.find(p => p.color === datosCamaraActual.presidenteBancada!.color);
|
||||
if (!partidoPresidente) return partidosOrdenados;
|
||||
|
||||
const adjustedPartyData = JSON.parse(JSON.stringify(partidosOrdenados));
|
||||
const partidoAjustar = adjustedPartyData.find((p: PartidoComposicionNacional) => p.id === partidoPresidente.id);
|
||||
|
||||
if (partidoAjustar) {
|
||||
const tipoBanca = datosCamaraActual.presidenteBancada.tipoBanca;
|
||||
if (tipoBanca === 'ganada' && partidoAjustar.bancasGanadas > 0) {
|
||||
partidoAjustar.bancasGanadas -= 1;
|
||||
} else if (tipoBanca === 'previa' && partidoAjustar.bancasFijos > 0) {
|
||||
partidoAjustar.bancasFijos -= 1;
|
||||
} else {
|
||||
if (partidoAjustar.bancasGanadas > 0) {
|
||||
partidoAjustar.bancasGanadas -= 1;
|
||||
} else if (partidoAjustar.bancasFijos > 0) {
|
||||
partidoAjustar.bancasFijos -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return adjustedPartyData;
|
||||
}, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]);
|
||||
|
||||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||||
return (
|
||||
<div className={styles.congresoContainer}>
|
||||
<div className={styles.congresoGrafico}>
|
||||
<div
|
||||
className={`${styles.congresoHemicicloWrapper} ${isHovering ? styles.isHovering : ''}`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{camaraActiva === 'diputados' ?
|
||||
<DiputadosNacionalesLayout
|
||||
partyData={partyDataParaLayout}
|
||||
presidenteBancada={datosCamaraActual.presidenteBancada || null}
|
||||
size={700}
|
||||
/> :
|
||||
<SenadoresNacionalesLayout
|
||||
partyData={partyDataParaLayout}
|
||||
presidenteBancada={datosCamaraActual.presidenteBancada || null}
|
||||
size={700}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.congresoFooter}>
|
||||
<div className={styles.footerLegend}>
|
||||
<div className={styles.footerLegendItem}>
|
||||
<span className={`${styles.legendIcon} ${styles.legendIconSolid}`}></span>
|
||||
<span>Bancas en juego</span>
|
||||
</div>
|
||||
<div className={styles.footerLegendItem}>
|
||||
<span className={`${styles.legendIcon} ${styles.legendIconRing}`}></span>
|
||||
<span>Bancas Fijas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footerTimestamp}>
|
||||
Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.congresoSummary}>
|
||||
<div className={styles.summaryHeader}>
|
||||
<h3>{datosCamaraActual.camaraNombre}</h3>
|
||||
<div className={styles.chamberTabs}>
|
||||
<button className={camaraActiva === 'diputados' ? styles.active : ''} onClick={() => setCamaraActiva('diputados')}>
|
||||
Diputados
|
||||
</button>
|
||||
<button className={camaraActiva === 'senadores' ? styles.active : ''} onClick={() => setCamaraActiva('senadores')}>
|
||||
Senadores
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.summaryMetric}>
|
||||
<span>Total de Bancas</span>
|
||||
<strong>{datosCamaraActual.totalBancas}</strong>
|
||||
</div>
|
||||
<div className={styles.summaryMetric}>
|
||||
<span>Bancas en Juego</span>
|
||||
<strong>{datosCamaraActual.bancasEnJuego}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<div className={styles.partidoListaContainer}>
|
||||
<ul className={styles.partidoLista}>
|
||||
{partidosOrdenados
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map((partido: PartidoComposicionNacional) => (
|
||||
<li key={partido.id}>
|
||||
<span className={styles.partidoColorBox} style={{ 'marginRight': '0.25rem', backgroundColor: partido.color || '#808080' }}></span>
|
||||
<span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
|
||||
<strong
|
||||
className={styles.partidoBancas}
|
||||
title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`}
|
||||
>
|
||||
{partido.bancasTotales}
|
||||
</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip id="party-tooltip" className={styles.partyTooltipContainer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
return (
|
||||
<Suspense fallback={<div className={`${styles.congresoContainer} ${styles.loading}`} style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}>
|
||||
<WidgetContent eleccionId={eleccionId} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
// src/features/legislativas/nacionales/HomeCarouselNacionalWidget.tsx
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getHomeResumenNacional } from '../../../apiService';
|
||||
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
|
||||
import { assetBaseUrl } from '../../../apiService';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Navigation, A11y } from 'swiper/modules';
|
||||
import { TfiMapAlt } from "react-icons/tfi";
|
||||
|
||||
// @ts-ignore
|
||||
import 'swiper/css';
|
||||
// @ts-ignore
|
||||
import 'swiper/css/navigation';
|
||||
import styles from './HomeCarouselWidget.module.css';
|
||||
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
categoriaId: number;
|
||||
titulo: string;
|
||||
mapLinkUrl: string;
|
||||
}
|
||||
|
||||
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const formatNumber = (num: number) => num.toLocaleString('es-AR');
|
||||
const formatDateTime = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return '...';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const HomeCarouselNacionalWidget = ({ eleccionId, categoriaId, titulo, mapLinkUrl }: Props) => {
|
||||
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const prevButtonClass = `prev-${uniqueId}`;
|
||||
const nextButtonClass = `next-${uniqueId}`;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['homeResumenNacional', eleccionId, categoriaId],
|
||||
queryFn: () => getHomeResumenNacional(eleccionId, categoriaId),
|
||||
refetchInterval: 180000,
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Cargando widget...</div>;
|
||||
if (error || !data) return <div>No se pudieron cargar los datos.</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
|
||||
<h2 className={styles.widgetTitle}>{titulo}</h2>
|
||||
<a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}>
|
||||
<TfiMapAlt />
|
||||
<span className={styles.buttonText}>Ver Mapa</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.carouselContainer}>
|
||||
<Swiper
|
||||
modules={[Navigation, A11y]}
|
||||
spaceBetween={16}
|
||||
slidesPerView={1.3}
|
||||
navigation={{
|
||||
prevEl: `.${prevButtonClass}`,
|
||||
nextEl: `.${nextButtonClass}`,
|
||||
}}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1.25, spaceBetween: 10 },
|
||||
430: { slidesPerView: 1.4, spaceBetween: 12 },
|
||||
640: { slidesPerView: 2.5 },
|
||||
1024: { slidesPerView: 3 },
|
||||
1200: { slidesPerView: 3.5 }
|
||||
}}
|
||||
>
|
||||
{data.resultados.map(candidato => (
|
||||
<SwiperSlide key={candidato.agrupacionId}>
|
||||
<div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
|
||||
<div className={styles.candidatePhotoWrapper}>
|
||||
<ImageWithFallback
|
||||
src={candidato.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={candidato.nombreCandidato ?? ''}
|
||||
className={styles.candidatePhoto}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.candidateDetails}>
|
||||
<div className={styles.candidateInfo}>
|
||||
{candidato.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.candidateName}>{candidato.nombreCandidato}</span>
|
||||
<span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.candidateResults}>
|
||||
<span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span>
|
||||
<span className={styles.votes}>{formatNumber(candidato.votos)} votos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div>
|
||||
<div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div>
|
||||
</div>
|
||||
|
||||
<div className={styles.topStatsBar}>
|
||||
<div>
|
||||
<span>Participación</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Mesas escrutadas</span>
|
||||
<span className={styles.shortText}>Escrutado</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos en blanco</span>
|
||||
<span className={styles.shortText}>En blanco</span>
|
||||
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos totales</span>
|
||||
<span className={styles.shortText}>Votos</span>
|
||||
<strong>{formatNumber(data.votosTotales)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.widgetFooter}>
|
||||
Última actualización: {formatDateTime(data.ultimaActualizacion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,201 @@
|
||||
// src/features/legislativas/nacionales/HomeCarouselProvincialWidget.tsx
|
||||
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select, { type SingleValue, type StylesConfig } from 'react-select';
|
||||
import { getHomeResumen, getProvincias } from '../../../apiService';
|
||||
import type { CatalogoItem } from '../../../types/types';
|
||||
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 styles from './HomeCarouselWidget.module.css';
|
||||
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
categoriaId: number | string;
|
||||
titulo: string;
|
||||
}
|
||||
|
||||
interface OptionType {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const PROVINCIAS_QUE_RENUEVAN_SENADORES = new Set(['01', '06', '08', '15', '16', '17', '22', '24']);
|
||||
const CATEGORIA_SENADORES = 2;
|
||||
|
||||
const customSelectStyles: StylesConfig<OptionType, false> = {
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
maxHeight: '180px',
|
||||
}),
|
||||
};
|
||||
|
||||
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const formatNumber = (num: number) => num.toLocaleString('es-AR');
|
||||
const formatDateTime = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return '...';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const HomeCarouselProvincialWidget = ({ eleccionId, categoriaId, titulo }: Props) => {
|
||||
const [selectedProvince, setSelectedProvince] = useState<OptionType | null>({ value: '01', label: 'CABA' });
|
||||
|
||||
const { data: provincias = [], isLoading: isLoadingProvincias } = useQuery<CatalogoItem[]>({
|
||||
queryKey: ['provincias'],
|
||||
queryFn: getProvincias,
|
||||
});
|
||||
|
||||
const provinceOptions: OptionType[] = useMemo(() => {
|
||||
const allOptions = provincias.map(p => ({ value: p.id, label: p.nombre }));
|
||||
if (Number(categoriaId) === CATEGORIA_SENADORES) {
|
||||
return allOptions.filter(opt => PROVINCIAS_QUE_RENUEVAN_SENADORES.has(opt.value));
|
||||
}
|
||||
return allOptions;
|
||||
}, [provincias, categoriaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (provinceOptions.length > 0) {
|
||||
if (!selectedProvince) {
|
||||
const defaultOption = provinceOptions.find(opt => opt.value === '01');
|
||||
setSelectedProvince(defaultOption || provinceOptions[0]);
|
||||
} else {
|
||||
const isSelectedStillValid = provinceOptions.some(opt => opt.value === selectedProvince.value);
|
||||
if (!isSelectedStillValid) {
|
||||
setSelectedProvince(provinceOptions[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [provinceOptions, selectedProvince]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['homeResumen', eleccionId, selectedProvince?.value, categoriaId],
|
||||
queryFn: () => getHomeResumen(eleccionId, selectedProvince!.value, Number(categoriaId)),
|
||||
enabled: !!selectedProvince,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const prevButtonClass = `prev-${uniqueId}`;
|
||||
const nextButtonClass = `next-${uniqueId}`;
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={styles.widgetHeader}>
|
||||
<h2 className={styles.widgetTitle}>{`${titulo} - ${selectedProvince?.label || '...'}`}</h2>
|
||||
<div className={styles.provinceSelector}>
|
||||
<Select
|
||||
value={selectedProvince}
|
||||
options={provinceOptions}
|
||||
onChange={(option: SingleValue<OptionType>) => option && setSelectedProvince(option)}
|
||||
isLoading={isLoadingProvincias}
|
||||
isSearchable={true}
|
||||
placeholder="Seleccionar provincia..."
|
||||
styles={customSelectStyles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(isLoading || !selectedProvince) && <div>Cargando resultados...</div>}
|
||||
{error && <div>No se pudieron cargar los datos.</div>}
|
||||
{data && selectedProvince && (
|
||||
<>
|
||||
<div className={styles.carouselContainer}>
|
||||
<Swiper
|
||||
modules={[Navigation, A11y]}
|
||||
spaceBetween={16}
|
||||
slidesPerView={1.3}
|
||||
navigation={{
|
||||
prevEl: `.${prevButtonClass}`,
|
||||
nextEl: `.${nextButtonClass}`,
|
||||
}}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1.25, spaceBetween: 10 },
|
||||
430: { slidesPerView: 1.4, spaceBetween: 12 },
|
||||
640: { slidesPerView: 2.5 },
|
||||
1024: { slidesPerView: 3 },
|
||||
1200: { slidesPerView: 3.5 }
|
||||
}}
|
||||
>
|
||||
{data.resultados.map(candidato => (
|
||||
<SwiperSlide key={candidato.agrupacionId}>
|
||||
<div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
|
||||
<div className={styles.candidatePhotoWrapper}>
|
||||
<ImageWithFallback
|
||||
src={candidato.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={candidato.nombreCandidato ?? ''}
|
||||
className={styles.candidatePhoto}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.candidateDetails}>
|
||||
<div className={styles.candidateInfo}>
|
||||
{candidato.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.candidateName}>{candidato.nombreCandidato}</span>
|
||||
<span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.candidateResults}>
|
||||
<span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span>
|
||||
<span className={styles.votes}>{formatNumber(candidato.votos)} votos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div>
|
||||
<div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div>
|
||||
</div>
|
||||
|
||||
<div className={styles.topStatsBar}>
|
||||
<div>
|
||||
<span>Participación</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Mesas escrutadas</span>
|
||||
<span className={styles.shortText}>Escrutado</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos en blanco</span>
|
||||
<span className={styles.shortText}>En blanco</span>
|
||||
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos totales</span>
|
||||
<span className={styles.shortText}>Votos</span>
|
||||
<strong>{formatNumber(data.votosTotales)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.widgetFooter}>
|
||||
Última actualización: {formatDateTime(data.ultimaActualizacion)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
/* src/features/legislativas/nacionales/HomeCarouselWidget.module.css */
|
||||
|
||||
.homeCarouselWidget {
|
||||
--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;
|
||||
}
|
||||
|
||||
.homeCarouselWidget,
|
||||
.homeCarouselWidget * {
|
||||
font-family: var(--font-family-sans) !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.homeCarouselWidget {
|
||||
background-color: var(--background-white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carouselContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.widgetHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.widgetTitle {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 900;
|
||||
color: var(--primary-text);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.provinceSelector {
|
||||
min-width: 180px;
|
||||
flex-shrink: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.mapLinkButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapLinkButton svg {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
|
||||
.mapLinkButton:hover {
|
||||
background-color: #0056b3;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.topStatsBar {
|
||||
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;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.topStatsBar>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;
|
||||
}
|
||||
|
||||
.topStatsBar>div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.topStatsBar span {
|
||||
font-size: 0.9rem;
|
||||
color: var(--secondary-text);
|
||||
}
|
||||
|
||||
.topStatsBar strong {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.candidateCard {
|
||||
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);
|
||||
border-left: 5px solid;
|
||||
border-left-color: var(--candidate-color, #ccc);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.candidatePhotoWrapper {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--candidate-color, #e9ecef);
|
||||
}
|
||||
|
||||
.candidatePhoto {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.candidateDetails {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.candidateInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.candidateName,
|
||||
.partyName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: var(--primary-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.candidateName {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.partyName {
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-text);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.candidateResults {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Estilo base para ambos botones */
|
||||
.navButton {
|
||||
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;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin-top: 0;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Usamos el pseudo-elemento ::after para mostrar el icono SVG como fondo */
|
||||
.navButton::after {
|
||||
content: '';
|
||||
/* Es necesario para que el pseudo-elemento se muestre */
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
/* Ajustamos el tamaño del icono dentro del botón */
|
||||
background-size: 75%;
|
||||
}
|
||||
|
||||
/* Posición y contenido específico para cada botón */
|
||||
.navButtonPrev {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
.navButtonPrev::after {
|
||||
/* SVG de flecha izquierda (chevron) codificado en Base64 */
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSIxNSA2IDkgMTIgMTUgMTgiPjwvcG9seWxpbmU+PC9zdmc+");
|
||||
}
|
||||
|
||||
.navButtonNext {
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.navButtonNext::after {
|
||||
/* SVG de flecha derecha (chevron) codificado en Base64 */
|
||||
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMTI1MjkiIHN0cm9rZS13aWR0aD0iMyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cG9seWxpbmUgcG9pbnRzPSI5IDYgMTUgMTIgOSAxOCI+PC9wb2x5bGluZT48L3N2Zz4=");
|
||||
}
|
||||
|
||||
/* Swiper añade esta clase al botón cuando está deshabilitado */
|
||||
.navButton.swiper-button-disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-slide) {
|
||||
background: transparent !important;
|
||||
color: initial !important;
|
||||
text-align: left !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 1px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* --- INICIO DE LA MODIFICACIÓN DE FLECHAS --- */
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-prev),
|
||||
.homeCarouselWidget :global(.swiper-button-next) {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity 0.2s;
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
transform: translateY(-50%) !important;
|
||||
margin-top: 0 !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-prev:after),
|
||||
.homeCarouselWidget :global(.swiper-button-next:after) {
|
||||
display: block !important;
|
||||
font-family: 'swiper-icons';
|
||||
font-size: 14px !important;
|
||||
font-weight: bold !important;
|
||||
color: var(--primary-text) !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-prev) {
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-next) {
|
||||
right: 10px !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-button-disabled) {
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.widgetFooter {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.shortText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.homeCarouselWidget .widgetHeader {
|
||||
/* Comportamiento por defecto en móvil: apilado y centrado */
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* NUEVA CLASE MODIFICADORA para los widgets con botón */
|
||||
.homeCarouselWidget .headerSingleLine {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .widgetTitle {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Ajuste para que el título vuelva a la izquierda en la vista de una línea */
|
||||
.headerSingleLine .widgetTitle {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provinceSelector {
|
||||
min-width: 100%;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.mapLinkButton {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .topStatsBar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .topStatsBar>div {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .topStatsBar>div:nth-child(odd) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.homeCarouselWidget .longText {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .shortText {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .topStatsBar span {
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .topStatsBar strong {
|
||||
font-size: 0.85rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Ajustamos los botones custom en mobile */
|
||||
.navButton {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.navButton::after {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.navButtonPrev {
|
||||
left: -10px;
|
||||
}
|
||||
|
||||
.navButtonNext {
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .candidateCard {
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .candidatePhotoWrapper {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .candidateName {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .percentage {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .votes {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.homeCarouselWidget .widgetFooter {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mantenemos estos estilos globales por si acaso */
|
||||
.homeCarouselWidget :global(.swiper-slide) {
|
||||
background: transparent !important;
|
||||
color: initial !important;
|
||||
text-align: left !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.homeCarouselWidget :global(.swiper-slide:not(:last-child)) .candidateCard::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 1px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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';
|
||||
import { TfiMapAlt } from "react-icons/tfi";
|
||||
|
||||
// @ts-ignore
|
||||
import 'swiper/css';
|
||||
// @ts-ignore
|
||||
import 'swiper/css/navigation';
|
||||
import styles from './HomeCarouselWidget.module.css';
|
||||
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
distritoId: string;
|
||||
categoriaId: number;
|
||||
titulo: string;
|
||||
mapLinkUrl: string;
|
||||
}
|
||||
|
||||
const formatPercent = (num: number | null | undefined) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const formatNumber = (num: number) => num.toLocaleString('es-AR');
|
||||
const formatDateTime = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return '...';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo, mapLinkUrl }: Props) => {
|
||||
const uniqueId = `swiper-${Math.random().toString(36).substring(2, 9)}`;
|
||||
const prevButtonClass = `prev-${uniqueId}`;
|
||||
const nextButtonClass = `next-${uniqueId}`;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['homeResumen', eleccionId, distritoId, categoriaId],
|
||||
queryFn: () => getHomeResumen(eleccionId, distritoId, categoriaId),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Cargando widget...</div>;
|
||||
if (error || !data) return <div>No se pudieron cargar los datos.</div>;
|
||||
|
||||
return (
|
||||
<div className={styles.homeCarouselWidget}>
|
||||
<div className={`${styles.widgetHeader} ${styles.headerSingleLine}`}>
|
||||
<h2 className={styles.widgetTitle}>{titulo}</h2>
|
||||
<a href={mapLinkUrl} className={`${styles.mapLinkButton} noAjax`}>
|
||||
<TfiMapAlt />
|
||||
<span className={styles.buttonText}>Ver Mapa</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className={styles.carouselContainer}>
|
||||
<Swiper
|
||||
modules={[Navigation, A11y]}
|
||||
spaceBetween={16}
|
||||
slidesPerView={1.3}
|
||||
navigation={{
|
||||
prevEl: `.${prevButtonClass}`,
|
||||
nextEl: `.${nextButtonClass}`,
|
||||
}}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1.25, spaceBetween: 10 },
|
||||
430: { slidesPerView: 1.4, spaceBetween: 12 },
|
||||
640: { slidesPerView: 2.5 },
|
||||
1024: { slidesPerView: 3 },
|
||||
1200: { slidesPerView: 3.5 }
|
||||
}}
|
||||
>
|
||||
{data.resultados.map(candidato => (
|
||||
<SwiperSlide key={candidato.agrupacionId}>
|
||||
<div className={styles.candidateCard} style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
|
||||
<div className={styles.candidatePhotoWrapper}>
|
||||
<ImageWithFallback
|
||||
src={candidato.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={candidato.nombreCandidato ?? ''}
|
||||
className={styles.candidatePhoto}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.candidateDetails}>
|
||||
<div className={styles.candidateInfo}>
|
||||
{candidato.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.candidateName}>{candidato.nombreCandidato}</span>
|
||||
<span className={styles.partyName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.candidateName}>{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.candidateResults}>
|
||||
<span className={styles.percentage}>{formatPercent(candidato.porcentaje)}</span>
|
||||
<span className={styles.votes}>{formatNumber(candidato.votos)} votos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className={`${styles.navButton} ${styles.navButtonPrev} ${prevButtonClass}`}></div>
|
||||
<div className={`${styles.navButton} ${styles.navButtonNext} ${nextButtonClass}`}></div>
|
||||
</div>
|
||||
|
||||
<div className={styles.topStatsBar}>
|
||||
<div>
|
||||
<span>Participación</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Mesas escrutadas</span>
|
||||
<span className={styles.shortText}>Escrutado</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos en blanco</span>
|
||||
<span className={styles.shortText}>En blanco</span>
|
||||
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.longText}>Votos totales</span>
|
||||
<span className={styles.shortText}>Votos</span>
|
||||
<strong>{formatNumber(data.votosTotales)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.widgetFooter}>
|
||||
Última actualización: {formatDateTime(data.ultimaActualizacion)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,579 @@
|
||||
/* src/features/legislativas/nacionales/PanelNacional.module.css */
|
||||
|
||||
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
|
||||
.panelNacionalContainer,
|
||||
.panelNacionalContainer * {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panelNacionalContainer {
|
||||
max-width: 900px;
|
||||
margin: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.headerTopRow {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA REACT-SELECT USANDO MÓDULOS --- */
|
||||
.categoriaSelectorContainer {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__control) {
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__control--is-focused) {
|
||||
border-color: #007bff !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__single-value) {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__menu) {
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
margin-top: 4px !important;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__option) {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__option--is-focused) {
|
||||
background-color: #f0f8ff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__option--is-selected) {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__indicator-separator) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__indicator) {
|
||||
color: #a0a0a0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.categoriaSelectorContainer :global(.categoriaSelector__indicator:hover) {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
|
||||
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
|
||||
.breadcrumbsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumbItem,
|
||||
.breadcrumbItemActual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.breadcrumbItem {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #e0e0e0;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumbItem:hover {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #d1d1d1;
|
||||
}
|
||||
|
||||
.breadcrumbItemActual {
|
||||
background-color: transparent;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.breadcrumbIcon {
|
||||
margin-right: 0.4rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumbSeparator {
|
||||
color: #a0a0a0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.panelMainContent {
|
||||
display: flex;
|
||||
height: 75vh;
|
||||
min-height: 500px;
|
||||
transition: all 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.mapaColumn {
|
||||
flex: 2;
|
||||
position: relative;
|
||||
transition: flex 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.resultadosColumn {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
transition: all 0.5s ease-in-out;
|
||||
min-width: 350px;
|
||||
}
|
||||
|
||||
.partidoFila {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-left: 5px solid;
|
||||
border-radius: 12px;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.partidoLogo {
|
||||
flex-shrink: 0;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.partidoLogo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.partidoMainContent {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem; /* Aumentamos el gap vertical para más aire */
|
||||
}
|
||||
|
||||
/* El contenedor de la barra */
|
||||
.partidoBarraConVotos {
|
||||
grid-column: 1 / 3;
|
||||
position: relative; /* Clave para superponer el texto */
|
||||
height: 28px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* La barra de progreso coloreada */
|
||||
.partidoBarraForeground {
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* La ÚNICA capa de texto, posicionada de forma absoluta */
|
||||
.partidoVotosEnBarra {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.animatedNumberWrapper {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.partidoTopRow { display: contents; }
|
||||
.partidoInfoWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partidoNombre {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: #212529;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.partidoNombreNormal {
|
||||
font-size: 0.9rem;
|
||||
color: #212529;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.candidatoNombre {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.partidoStats { flex-shrink: 0; text-align: right; padding-left: 1rem; }
|
||||
.partidoPorcentaje { font-size: 1.15rem; font-weight: 700; display: block; }
|
||||
|
||||
.partidoBarraBackground {
|
||||
height: 16px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.partidoBarraForeground {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.panelEstadoRecuento {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.estadoItem {
|
||||
width: 95px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estadoItem span {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA MAPA --- */
|
||||
/* --- INICIO DE LA CORRECCIÓN --- */
|
||||
.mapaComponenteContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative; /* Esta línea es la que faltaba */
|
||||
overflow: hidden;
|
||||
}
|
||||
/* --- FIN DE LA CORRECCIÓN --- */
|
||||
|
||||
.mapaRenderArea { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
||||
|
||||
.mapaVolverBtn,
|
||||
.zoomBtn {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e0e0e0; /* Borde más sutil */
|
||||
border-radius: 8px; /* Bordes más suaves */
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08); /* Sombra más pronunciada y moderna */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease-in-out; /* Transición suave para todos los efectos */
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.mapaVolverBtn:hover,
|
||||
.zoomBtn:hover:not(:disabled) {
|
||||
border-color: #007bff; /* Borde de acento */
|
||||
color: #007bff; /* Icono/texto de acento */
|
||||
transform: translateY(-2px); /* Efecto de "levantar" */
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.mapaVolverBtn:active,
|
||||
.zoomBtn:active:not(:disabled) {
|
||||
transform: translateY(0px); /* Botón "presionado" */
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); /* Sombra interior */
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.mapaVolverBtn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.rsm-zoomable-group) { transition: transform 0.75s ease-in-out; }
|
||||
:global(.rsm-zoomable-group.panning) { transition: none; }
|
||||
|
||||
.panelMainContent.panelCollapsed .mapaColumn { flex: 1 1 100%; }
|
||||
|
||||
.panelMainContent.panelCollapsed .resultadosColumn {
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panelToggleBtn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 10px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
width: 30px;
|
||||
height: 50px;
|
||||
border: 1px solid #ccc;
|
||||
background-color: white;
|
||||
border-radius: 4px 0 0 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
color: #555;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.panelToggleBtn:hover { background-color: #f0f0f0; }
|
||||
|
||||
:global(.rsm-geography) {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.25px;
|
||||
outline: none;
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
:global(.rsm-geography:not(.selected):hover) {
|
||||
filter: brightness(1.25);
|
||||
stroke: #ffffff;
|
||||
stroke-width: 0.25px;
|
||||
paint-order: stroke;
|
||||
}
|
||||
:global(.rsm-geography.selected) {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.25px;
|
||||
filter: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
:global(.rsm-geography-faded), :global(.rsm-geography-faded-municipality) {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
:global(.caba-comuna-geography) {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.05px;
|
||||
}
|
||||
:global(.caba-comuna-geography:not(.selected):hover) {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.055px;
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
:global(.caba-comuna-geography.selected) {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.075px;
|
||||
}
|
||||
|
||||
.transitionSpinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.transitionSpinner::after {
|
||||
content: '';
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid rgba(0, 0, 0, 0.2);
|
||||
border-top-color: #007bff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.cabaMagnifierContainer { position: absolute; height: auto; transform: translate(-50%, -50%); pointer-events: none; }
|
||||
.cabaLupaSvg { width: 100%; height: auto; pointer-events: none; }
|
||||
.cabaLupaInteractiveArea { pointer-events: all; cursor: pointer; filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25)); transition: transform 0.2s ease-in-out; }
|
||||
.cabaLupaInteractiveArea:hover { filter: brightness(1.15); stroke: #ffffff; stroke-width: 0.25px; }
|
||||
|
||||
.skeletonFila div {
|
||||
background: #f6f7f8;
|
||||
background-image: linear-gradient(to right, #f6f7f8 0%, #edeef1 20%, #f6f7f8 40%, #f6f7f8 100%);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 800px 104px;
|
||||
animation: shimmer 1s linear infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeletonLogo { width: 65px; height: 65px; }
|
||||
.skeletonText { height: 1em; }
|
||||
.skeletonBar { height: 20px; margin-top: 4px; }
|
||||
|
||||
.zoomControlsContainer {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px; /* Un poco más de espacio */
|
||||
}
|
||||
|
||||
/* Estilos específicos para los botones de zoom */
|
||||
.zoomBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.zoomIconWrapper svg {
|
||||
width: 22px; /* Iconos ligeramente más grandes */
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Estilo para el botón deshabilitado */
|
||||
.zoomBtn:disabled,
|
||||
.zoomBtn.disabled { /* Cubrimos ambos casos */
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
:global(.map-locked .rsm-geography) { cursor: pointer; }
|
||||
:global(.map-pannable .rsm-geography) { cursor: grab; }
|
||||
|
||||
.headerBottomRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.municipioSearchContainer { min-width: 280px; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.panelNacionalContainer { display: flex; flex-direction: column; height: 100vh; padding: 0; border: none; border-radius: 0; }
|
||||
.panelHeader { flex-shrink: 0; padding: 1rem; border-radius: 0; }
|
||||
.panelMainContent { flex-grow: 1; position: relative; height: auto; min-height: 0; }
|
||||
.panelToggleBtn { display: none; }
|
||||
.headerTopRow { flex-direction: column; align-items: flex-start; gap: 1rem; }
|
||||
.categoriaSelectorContainer { width: 100%; }
|
||||
.mapaColumn,
|
||||
.resultadosColumn { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; }
|
||||
.mapaColumn { z-index: 10; }
|
||||
.resultadosColumn { padding: 1rem; overflow-y: auto; z-index: 15; }
|
||||
.panelMainContent.mobile-view-mapa .resultadosColumn { opacity: 0; visibility: hidden; pointer-events: none; }
|
||||
.panelMainContent.mobile-view-resultados .mapaColumn { opacity: 0; visibility: hidden; pointer-events: none; }
|
||||
.resultadosColumn { padding: 0.5rem; padding-bottom: 50px; }
|
||||
.mapaColumn .mapaComponenteContainer, .mapaColumn .mapaRenderArea { height: 100%; }
|
||||
.panelPartidosContainer { padding-bottom: 0; }
|
||||
.zoomControlsContainer, .mapaVolverBtn { top: 15px; }
|
||||
.headerBottomRow { flex-direction: column; align-items: stretch; gap: 1rem; }
|
||||
.municipioSearchContainer { min-width: 100%; }
|
||||
|
||||
@media (max-width: 900px) and (orientation: landscape) {
|
||||
.panelMainContent { display: flex; flex-direction: row; position: static; height: 85vh; min-height: 400px; }
|
||||
.mapaColumn,
|
||||
.resultadosColumn { position: static; height: auto; width: auto; opacity: 1; visibility: visible; pointer-events: auto; flex: 3; overflow-y: auto; }
|
||||
.resultadosColumn { flex: 2; min-width: 300px; }
|
||||
.mobileResultsCardContainer { display: none; }
|
||||
.panelToggleBtn { display: flex; }
|
||||
}
|
||||
}
|
||||
|
||||
.mobileResultsCardContainer {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
width: 95%;
|
||||
max-width: 450px;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.mobileResultsCardContainer.view-resultados .collapsibleSection { display: none; }
|
||||
.mobileResultsCardContainer.view-resultados .mobileCardViewToggle { border-top: none; }
|
||||
.collapsibleSection { display: flex; flex-direction: column; }
|
||||
.mobileResultsHeader { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; cursor: pointer; }
|
||||
.mobileResultsHeader .headerInfo { display: flex; align-items: baseline; gap: 12px; }
|
||||
.mobileResultsHeader .headerInfo h4 { margin: 0; font-size: 1.2rem; font-weight: 700; }
|
||||
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.8rem; color: #6c757d; font-weight: 500; text-transform: uppercase; }
|
||||
.mobileResultsHeader .headerToggleIcon { font-size: 1.5rem; color: #007bff; transition: transform 0.3s; }
|
||||
.mobileResultsContent { max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, padding 0.3s ease-in-out; padding: 0 15px; border-top: 1px solid transparent; }
|
||||
.mobileResultsCardContainer.expanded .mobileResultsContent { max-height: 500px; opacity: 1; padding: 5px 15px 15px 15px; border-top-color: #e0e0e0; }
|
||||
.mobileResultRow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; border-left: 4px solid; padding-left: 8px; }
|
||||
.mobileResultRow:last-child { border-bottom: none; }
|
||||
.mobileResultLogo { flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; }
|
||||
.mobileResultLogo img { width: 100%; height: 100%; border-radius: 8px; }
|
||||
.mobileResultInfo { flex-grow: 1; min-width: 0; }
|
||||
.mobileResultPartyName { display: block; font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mobileResultCandidateName { display: block; font-size: 0.75rem; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.mobileResultStats { display: flex; flex-direction: column; align-items: flex-end; flex-shrink: 0; }
|
||||
.mobileResultStats strong { font-size: 0.95rem; font-weight: 700; }
|
||||
.mobileResultStats span { font-size: 0.7rem; color: #6c757d; }
|
||||
.noResultsText { padding: 1rem; text-align: center; color: #6c757d; font-size: 0.9rem; }
|
||||
.mobileCardViewToggle { display: flex; padding: 5px; background-color: rgba(230, 230, 230, 0.6); border-top: 1px solid rgba(0, 0, 0, 0.08); }
|
||||
.mobileCardViewToggle .toggleBtn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 15px; border: none; background-color: transparent; border-radius: 25px; cursor: pointer; font-size: 1rem; font-weight: 500; color: #555; transition: all 0.2s ease-in-out; }
|
||||
.mobileCardViewToggle .toggleBtn.active { background-color: #007bff; color: white; box-shadow: 0 2px 5px rgba(0, 123, 255, 0.2); }
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.mobileResultsHeader { padding: 4px 10px; }
|
||||
.mobileResultsHeader .headerInfo h4 { font-size: 0.75rem; text-transform: uppercase; }
|
||||
.mobileResultsHeader .headerInfo .headerActionText { font-size: 0.7rem; }
|
||||
.mobileCardViewToggle .toggleBtn { padding: 6px 10px; font-size: 0.8rem; }
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
|
||||
|
||||
import { useMemo, useState, Suspense, useEffect } from 'react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { getPanelElectoral } from '../../../apiService';
|
||||
import { MapaNacional } from './components/MapaNacional';
|
||||
import { PanelResultados } from './components/PanelResultados';
|
||||
import { Breadcrumbs } from './components/Breadcrumbs';
|
||||
import { MunicipioSearch } from './components/MunicipioSearch';
|
||||
import styles from './PanelNacional.module.css';
|
||||
import Select from 'react-select';
|
||||
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
|
||||
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
import { ImageWithFallback } from '../../../components/common/ImageWithFallback';
|
||||
import { assetBaseUrl } from '../../../apiService';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
// --- SUB-COMPONENTE PARA UNA FILA DE RESULTADO ---
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
const ResultRow = ({ partido }: { partido: ResultadoTicker }) => (
|
||||
<div className={styles.mobileResultRow} style={{ borderLeftColor: partido.color || '#ccc' }}>
|
||||
<div className={styles.mobileResultLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
|
||||
</div>
|
||||
<div className={styles.mobileResultInfo}>
|
||||
{partido.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.mobileResultPartyName}>{partido.nombreCandidato}</span>
|
||||
<span className={styles.mobileResultCandidateName}>{partido.nombreCorto || partido.nombre}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.mobileResultPartyName}>{partido.nombreCorto || partido.nombre}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.mobileResultStats}>
|
||||
<strong>{formatPercent(partido.porcentaje)}</strong>
|
||||
<span>{partido.votos.toLocaleString('es-AR')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// --- COMPONENTE REFACTORIZADO PARA LA TARJETA MÓVIL ---
|
||||
interface MobileResultsCardProps {
|
||||
eleccionId: number;
|
||||
ambitoId: string | null;
|
||||
categoriaId: number;
|
||||
ambitoNombre: string;
|
||||
ambitoNivel: 'pais' | 'provincia' | 'municipio';
|
||||
mobileView: 'mapa' | 'resultados';
|
||||
setMobileView: (view: 'mapa' | 'resultados') => void;
|
||||
}
|
||||
|
||||
const MobileResultsCard = ({
|
||||
eleccionId, ambitoId, categoriaId, ambitoNombre, ambitoNivel, mobileView, setMobileView
|
||||
}: MobileResultsCardProps) => {
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||||
queryKey: ['panelElectoral', eleccionId, ambitoId, categoriaId, ambitoNivel],
|
||||
queryFn: () => getPanelElectoral(eleccionId, ambitoId, categoriaId, ambitoNivel),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(ambitoNivel === 'municipio');
|
||||
}, [ambitoNivel]);
|
||||
|
||||
const topResults = data.resultadosPanel.slice(0, 3);
|
||||
|
||||
if (topResults.length === 0 && ambitoNivel === 'pais') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cardClasses = [
|
||||
styles.mobileResultsCardContainer,
|
||||
isExpanded ? styles.expanded : '',
|
||||
styles[`view-${mobileView}`]
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={cardClasses}>
|
||||
<div className={styles.collapsibleSection}>
|
||||
<div className={styles.mobileResultsHeader} onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h4>{ambitoNombre}</h4>
|
||||
<span className={styles.headerActionText}>{isExpanded ? 'Ocultar resultados' : 'Ver top 3'}</span>
|
||||
</div>
|
||||
<div className={styles.headerToggleIcon}>
|
||||
{isExpanded ? <FiChevronDown /> : <FiChevronUp />}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mobileResultsContent}>
|
||||
{topResults.length > 0 ? (
|
||||
topResults.map(partido => <ResultRow key={partido.id} partido={partido} />)
|
||||
) : (
|
||||
<p className={styles.noResultsText}>No hay resultados para esta selección.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.mobileCardViewToggle}>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${mobileView === 'mapa' ? styles.active : ''}`}
|
||||
onClick={() => setMobileView('mapa')}
|
||||
>
|
||||
<FiMap />
|
||||
<span>Mapa</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${mobileView === 'resultados' ? styles.active : ''}`}
|
||||
onClick={() => setMobileView('resultados')}
|
||||
>
|
||||
<FiList />
|
||||
<span>Detalles</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- WIDGET PRINCIPAL ---
|
||||
interface PanelNacionalWidgetProps {
|
||||
eleccionId: number;
|
||||
}
|
||||
|
||||
type AmbitoState = {
|
||||
id: string | null;
|
||||
nivel: 'pais' | 'provincia' | 'municipio';
|
||||
nombre: string;
|
||||
provinciaNombre?: string;
|
||||
provinciaDistritoId?: string | null;
|
||||
};
|
||||
|
||||
const CATEGORIAS_NACIONALES = [
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
];
|
||||
|
||||
const PanelContenido = ({ eleccionId, ambitoActual, categoriaId }: { eleccionId: number, ambitoActual: AmbitoState, categoriaId: number }) => {
|
||||
const { data } = useSuspenseQuery<PanelElectoralDto>({
|
||||
queryKey: ['panelElectoral', eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel],
|
||||
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId, ambitoActual.nivel),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
if (data.sinDatos) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: '#666' }}>
|
||||
<h4>Sin Resultados Detallados</h4>
|
||||
<p>Aún no hay datos disponibles para esta selección.</p>
|
||||
<p>Por favor, intente de nuevo más tarde.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <PanelResultados resultados={data.resultadosPanel} estadoRecuento={data.estadoRecuento} />;
|
||||
};
|
||||
|
||||
export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [ambitoActual, setAmbitoActual] = useState<AmbitoState>({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||||
const [categoriaId, setCategoriaId] = useState<number>(3);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
|
||||
const isMobile = useMediaQuery('(max-width: 800px)');
|
||||
|
||||
const handleAmbitoSelect = (nuevoAmbitoId: string, nuevoNivel: 'provincia' | 'municipio', nuevoNombre: string) => {
|
||||
if (nuevoNivel === 'municipio') {
|
||||
toast.promise(
|
||||
queryClient.invalidateQueries({ queryKey: ['panelElectoral', eleccionId, nuevoAmbitoId, categoriaId, nuevoNivel] }),
|
||||
{
|
||||
loading: `Cargando datos de ${nuevoNombre}...`,
|
||||
error: <b>No se pudieron cargar los datos.</b>,
|
||||
}
|
||||
);
|
||||
}
|
||||
setAmbitoActual(prev => ({
|
||||
id: nuevoAmbitoId,
|
||||
nivel: nuevoNivel,
|
||||
nombre: nuevoNombre,
|
||||
provinciaNombre: nuevoNivel === 'municipio' ? prev.provinciaNombre : (nuevoNivel === 'provincia' ? nuevoNombre : undefined),
|
||||
provinciaDistritoId: nuevoNivel === 'provincia' ? nuevoAmbitoId : prev.provinciaDistritoId
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetToPais = () => {
|
||||
setAmbitoActual({ id: null, nivel: 'pais', nombre: 'Argentina', provinciaDistritoId: null });
|
||||
};
|
||||
|
||||
const handleVolverAProvincia = () => {
|
||||
if (ambitoActual.provinciaDistritoId && ambitoActual.provinciaNombre) {
|
||||
setAmbitoActual({
|
||||
id: ambitoActual.provinciaDistritoId,
|
||||
nivel: 'provincia',
|
||||
nombre: ambitoActual.provinciaNombre,
|
||||
provinciaDistritoId: ambitoActual.provinciaDistritoId,
|
||||
provinciaNombre: ambitoActual.provinciaNombre,
|
||||
});
|
||||
} else {
|
||||
handleResetToPais();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCategoria = useMemo(() =>
|
||||
CATEGORIAS_NACIONALES.find(c => c.value === categoriaId),
|
||||
[categoriaId]
|
||||
);
|
||||
|
||||
const mainContentClasses = [
|
||||
styles.panelMainContent,
|
||||
!isPanelOpen ? styles.panelCollapsed : '',
|
||||
isMobile ? styles[`mobile-view-${mobileView}`] : ''
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className={styles.panelNacionalContainer}>
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
containerClassName={styles.widgetToasterContainer}
|
||||
/>
|
||||
<header className={styles.panelHeader}>
|
||||
<div className={styles.headerTopRow}>
|
||||
<Select
|
||||
options={CATEGORIAS_NACIONALES}
|
||||
value={selectedCategoria}
|
||||
onChange={(option) => option && setCategoriaId(option.value)}
|
||||
classNamePrefix="categoriaSelector"
|
||||
className={styles.categoriaSelectorContainer}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.headerBottomRow}>
|
||||
<Breadcrumbs
|
||||
nivel={ambitoActual.nivel}
|
||||
nombreAmbito={ambitoActual.nombre}
|
||||
nombreProvincia={ambitoActual.provinciaNombre}
|
||||
onReset={handleResetToPais}
|
||||
onVolverProvincia={handleVolverAProvincia}
|
||||
/>
|
||||
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
|
||||
<MunicipioSearch
|
||||
distritoId={ambitoActual.provinciaDistritoId}
|
||||
onMunicipioSelect={(municipioId, municipioNombre) =>
|
||||
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className={mainContentClasses}>
|
||||
<div className={styles.mapaColumn}>
|
||||
<button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '›' : '‹'} </button>
|
||||
|
||||
<Suspense fallback={<div className={styles.spinner} />}>
|
||||
<MapaNacional eleccionId={eleccionId} categoriaId={categoriaId} nivel={ambitoActual.nivel} nombreAmbito={ambitoActual.nombre} nombreProvinciaActiva={ambitoActual.provinciaNombre} provinciaDistritoId={ambitoActual.provinciaDistritoId ?? null} onAmbitoSelect={handleAmbitoSelect} onVolver={ambitoActual.nivel === 'municipio' ? handleVolverAProvincia : handleResetToPais} isMobileView={isMobile} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className={styles.resultadosColumn}>
|
||||
<Suspense fallback={<div className={styles.spinner} />}>
|
||||
<PanelContenido eleccionId={eleccionId} ambitoActual={ambitoActual} categoriaId={categoriaId} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{isMobile && (
|
||||
<MobileResultsCard
|
||||
eleccionId={eleccionId}
|
||||
ambitoId={ambitoActual.id}
|
||||
categoriaId={categoriaId}
|
||||
ambitoNombre={ambitoActual.nombre}
|
||||
ambitoNivel={ambitoActual.nivel}
|
||||
mobileView={mobileView}
|
||||
setMobileView={setMobileView}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.module.css */
|
||||
|
||||
/* --- SOLUCIÓN PARA FUENTES Y ESTILOS GLOBALES --- */
|
||||
.cardsWidgetContainer,
|
||||
.cardsWidgetContainer * {
|
||||
font-family: "Roboto", system-ui, sans-serif !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Contenedor Principal del Widget y Variables --- */
|
||||
.cardsWidgetContainer {
|
||||
--card-border-color: #e0e0e0;
|
||||
--card-bg-color: #ffffff;
|
||||
--card-header-bg-color: #e6f1fd;
|
||||
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--primary-accent-color: #007bff;
|
||||
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.cardsWidgetContainer h2 {
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
}
|
||||
|
||||
/* --- Grilla de Tarjetas --- */
|
||||
.cardsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- Tarjeta Individual --- */
|
||||
.provinciaCard {
|
||||
background-color: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--card-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Cabecera de la Tarjeta --- */
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--card-header-bg-color);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
}
|
||||
|
||||
.headerInfo h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.headerInfo span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.headerMap {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: #f7fbff;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.mapSvgContainer, .mapPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.mapSvgContainer svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.mapPlaceholder.error {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
/* --- Cuerpo de la Tarjeta --- */
|
||||
.cardBody {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.candidatoRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.candidatoRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-left: 5px solid;
|
||||
border-radius: 12px;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.candidatoFotoWrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.candidatoFoto {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.candidatoData {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.candidatoNombre {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.candidatoPartido {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.progressBarContainer {
|
||||
height: 16px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.candidatoStats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.statsPercent {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.statsVotos {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statsBancas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-accent-color);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.statsBancas span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* --- Pie de la Tarjeta --- */
|
||||
.cardFooter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
background-color: var(--card-header-bg-color);
|
||||
border-top: 1px solid var(--card-border-color);
|
||||
padding: 0.75rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cardFooter div {
|
||||
border-right: 1px solid var(--card-border-color);
|
||||
}
|
||||
|
||||
.cardFooter div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.cardFooter span {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.cardFooter strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* --- Media Query para Móvil --- */
|
||||
@media (max-width: 480px) {
|
||||
.cardsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.headerInfo h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.candidatoPartido.mainTitle {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-transform: none;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA LA ESTRUCTURA MULTI-CATEGORÍA --- */
|
||||
.categoriaBloque {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.categoriaBloque + .categoriaBloque {
|
||||
border-top: 1px dashed var(--card-border-color);
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.categoriaTitulo {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.categoriaBloque .cardFooter {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--card-border-color);
|
||||
padding: 0.75rem 0;
|
||||
margin-top: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categoriaBloque .cardFooter div {
|
||||
border-right: 1px solid var(--card-border-color);
|
||||
}
|
||||
.categoriaBloque .cardFooter div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.tsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenPorProvincia } from '../../../apiService';
|
||||
import { ProvinciaCard } from './components/ProvinciaCard';
|
||||
import styles from './ResultadosNacionalesCardsWidget.module.css';
|
||||
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
focoDistritoId?: string;
|
||||
focoCategoriaId?: number;
|
||||
cantidadResultados?: number;
|
||||
mostrarBancas?: boolean;
|
||||
}
|
||||
|
||||
export const ResultadosNacionalesCardsWidget = ({
|
||||
eleccionId,
|
||||
focoDistritoId,
|
||||
focoCategoriaId,
|
||||
cantidadResultados,
|
||||
mostrarBancas = false
|
||||
}: Props) => {
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['resumenPorProvincia', eleccionId, focoDistritoId, focoCategoriaId, cantidadResultados],
|
||||
|
||||
queryFn: () => getResumenPorProvincia(eleccionId, {
|
||||
focoDistritoId,
|
||||
focoCategoriaId,
|
||||
cantidadResultados
|
||||
}),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Cargando resultados por provincia...</div>;
|
||||
if (error) return <div>Error al cargar los datos.</div>;
|
||||
if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div>
|
||||
|
||||
return (
|
||||
<section className={styles.cardsWidgetContainer}>
|
||||
<div className={styles.cardsGrid}>
|
||||
{data?.map(provinciaData => (
|
||||
<ProvinciaCard
|
||||
key={provinciaData.provinciaId}
|
||||
data={provinciaData}
|
||||
mostrarBancas={mostrarBancas}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
/* src/components/widgets/ResumenNacionalWidget.module.css */
|
||||
.widgetContainer {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.subHeader h4 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.categoriaSelector {
|
||||
min-width: 230px;
|
||||
}
|
||||
|
||||
.resultsTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.resultsTable thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resultsTable td {
|
||||
padding: 3px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provinciaBlock {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.provinciaBlock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.provinciaNombre {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
color: #333;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.provinciaEscrutado {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
padding-top: 1rem;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.partidoNombre {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.partidoPorcentaje {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
/* --- INICIO DE ESTILOS PARA MÓVILES --- */
|
||||
@media (max-width: 768px) {
|
||||
.subHeader {
|
||||
flex-direction: column; /* Apila el título y el selector */
|
||||
align-items: center; /* Centra los elementos */
|
||||
gap: 0.75rem; /* Añade espacio entre ellos */
|
||||
}
|
||||
|
||||
.subHeader h4 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categoriaSelector {
|
||||
width: 100%; /* Hace que el selector ocupe todo el ancho */
|
||||
min-width: unset; /* Elimina el ancho mínimo que interfiere */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// src/components/widgets/ResumenNacionalWidget.tsx
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getResumenNacionalPorProvincia } from '../../../apiService';
|
||||
import styles from './ResumenNacionalWidget.module.css';
|
||||
|
||||
const ELECCION_ID = 2; // Exclusivo para elecciones nacionales
|
||||
const CATEGORIAS_NACIONALES = [
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
];
|
||||
|
||||
// 1. Mapa para definir el orden y número de cada provincia según el PDF
|
||||
const PROVINCE_ORDER_MAP: Record<string, number> = {
|
||||
'02': 1, // Buenos Aires
|
||||
'03': 2, // Catamarca
|
||||
'06': 3, // Chaco
|
||||
'07': 4, // Chubut
|
||||
'04': 5, // Córdoba
|
||||
'05': 6, // Corrientes
|
||||
'08': 7, // Entre Ríos
|
||||
'09': 8, // Formosa
|
||||
'10': 9, // Jujuy
|
||||
'11': 10, // La Pampa
|
||||
'12': 11, // La Rioja
|
||||
'13': 12, // Mendoza
|
||||
'14': 13, // Misiones
|
||||
'15': 14, // Neuquén
|
||||
'16': 15, // Río Negro
|
||||
'17': 16, // Salta
|
||||
'18': 17, // San Juan
|
||||
'19': 18, // San Luis
|
||||
'20': 19, // Santa Cruz
|
||||
'21': 20, // Santa Fe
|
||||
'22': 21, // Santiago del Estero
|
||||
'23': 22, // Tierra del Fuego
|
||||
'24': 23, // Tucumán
|
||||
'01': 24, // CABA
|
||||
};
|
||||
|
||||
|
||||
export const ResumenNacionalWidget = () => {
|
||||
const [categoria, setCategoria] = useState(CATEGORIAS_NACIONALES[0]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['resumenNacional', ELECCION_ID, categoria.value],
|
||||
queryFn: () => getResumenNacionalPorProvincia(ELECCION_ID, categoria.value),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
// 2. Ordenar los datos de la API usando el mapa de ordenamiento
|
||||
const sortedData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
return [...data].sort((a, b) => {
|
||||
const orderA = PROVINCE_ORDER_MAP[a.provinciaId] ?? 99;
|
||||
const orderB = PROVINCE_ORDER_MAP[b.provinciaId] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.subHeader}>
|
||||
<h4>{categoria.label}</h4>
|
||||
<Select
|
||||
className={styles.categoriaSelector}
|
||||
options={CATEGORIAS_NACIONALES}
|
||||
value={categoria}
|
||||
onChange={(opt) => setCategoria(opt!)}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
{isLoading && <p>Cargando resumen nacional...</p>}
|
||||
{error && <p style={{ color: 'red' }}>Error al cargar los datos.</p>}
|
||||
{sortedData && (
|
||||
<table className={styles.resultsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Concepto</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{sortedData.map((provincia) => (
|
||||
<tbody key={provincia.provinciaId} className={styles.provinciaBlock}>
|
||||
<tr>
|
||||
{/* 3. Añadir el número antes del nombre */}
|
||||
<td className={styles.provinciaNombre}>{`${PROVINCE_ORDER_MAP[provincia.provinciaId]}- ${provincia.provinciaNombre}`}</td>
|
||||
<td className={styles.provinciaEscrutado}>ESCR. {formatPercent(provincia.porcentajeEscrutado)}</td>
|
||||
</tr>
|
||||
{provincia.resultados.map((partido, index) => (
|
||||
<tr key={index}>
|
||||
<td className={styles.partidoNombre}>{partido.nombre}</td>
|
||||
<td className={styles.partidoPorcentaje}>{formatPercent(partido.porcentaje)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// src/features/legislativas/nacionales/TablaConurbanoWidget.tsx
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTablaConurbano } from '../../../apiService';
|
||||
import styles from './TablaResultadosWidget.module.css';
|
||||
|
||||
export const TablaConurbanoWidget = () => {
|
||||
const ELECCION_ID = 2;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tablaConurbano', ELECCION_ID],
|
||||
queryFn: () => getTablaConurbano(ELECCION_ID),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.header}>
|
||||
<h3>Diputados Nacionales</h3>
|
||||
</div>
|
||||
{isLoading && <p>Cargando resultados...</p>}
|
||||
{error && <p>Error al cargar los datos.</p>}
|
||||
{data && (
|
||||
<table className={styles.resultsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Distrito</th>
|
||||
<th>1ra Fuerza</th>
|
||||
<th>%</th>
|
||||
<th>2da Fuerza</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((fila, index) => (
|
||||
<tr key={fila.ambitoId}>
|
||||
<td className={styles.distritoCell}>
|
||||
<span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}
|
||||
</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza1Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
/* src/features/legislativas/nacionales/TablaResultadosWidget.module.css */
|
||||
.widgetContainer {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.categoriaSelector {
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.resultsTable {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.resultsTable th,
|
||||
.resultsTable td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.resultsTable th {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.distritoCell {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fuerzaCell {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.porcentajeCell {
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.seccionHeader td {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #007bff;
|
||||
border-top: 2px solid #007bff;
|
||||
border-bottom: 2px solid #007bff;
|
||||
}
|
||||
|
||||
.distritoIndex {
|
||||
font-weight: 400;
|
||||
color: #6c757d;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- INICIO DE ESTILOS PARA MÓVILES --- */
|
||||
@media (max-width: 768px) {
|
||||
.widgetContainer {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.resultsTable thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resultsTable,
|
||||
.resultsTable tbody {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 1. Cada TR es una grilla */
|
||||
.resultsTable tr {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
/* Columna para nombres, columna para % */
|
||||
grid-template-rows: auto auto auto;
|
||||
/* Fila para distrito, 1ra fuerza, 2da fuerza */
|
||||
gap: 4px 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.resultsTable tr:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resultsTable td {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 2. Posicionamos cada celda en la grilla */
|
||||
.distritoCell {
|
||||
grid-column: 1 / -1;
|
||||
/* Ocupa toda la primera fila */
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(2) {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.porcentajeCell:nth-of-type(3) {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(4) {
|
||||
grid-row: 3;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.porcentajeCell:nth-of-type(5) {
|
||||
grid-row: 3;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
/* 3. Añadimos los labels "1ra:" y "2da:" con pseudo-elementos */
|
||||
.fuerzaCell::before {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(2)::before {
|
||||
content: '1ra:';
|
||||
}
|
||||
|
||||
.fuerzaCell:nth-of-type(4)::before {
|
||||
content: '2da:';
|
||||
}
|
||||
|
||||
/* Ajustes de alineación */
|
||||
.fuerzaCell {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.porcentajeCell {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.seccionHeader td {
|
||||
display: block;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/features/legislativas/nacionales/TablaSeccionesWidget.tsx
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTablaSecciones } from '../../../apiService';
|
||||
import styles from './TablaResultadosWidget.module.css';
|
||||
|
||||
export const TablaSeccionesWidget = () => {
|
||||
const ELECCION_ID = 2;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tablaSecciones', ELECCION_ID],
|
||||
queryFn: () => getTablaSecciones(ELECCION_ID),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const formatPercent = (num: number) => `${num.toFixed(2)}%`;
|
||||
|
||||
return (
|
||||
<div className={styles.widgetContainer}>
|
||||
<div className={styles.header}>
|
||||
<h3>Diputados Nacionales</h3>
|
||||
</div>
|
||||
{isLoading && <p>Cargando resultados...</p>}
|
||||
{error && <p>Error al cargar los datos.</p>}
|
||||
{data && (
|
||||
<table className={styles.resultsTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Municipio</th>
|
||||
<th>1ra Fuerza</th>
|
||||
<th>%</th>
|
||||
<th>2da Fuerza</th>
|
||||
<th>%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((seccion) => (
|
||||
<React.Fragment key={seccion.seccionId}>
|
||||
<tr className={styles.seccionHeader}>
|
||||
<td colSpan={5}>{seccion.nombre}</td>
|
||||
</tr>
|
||||
{seccion.municipios.map((fila, index) => (
|
||||
<tr key={fila.ambitoId}>
|
||||
<td className={styles.distritoCell}>
|
||||
<span className={styles.distritoIndex}>{index + 1}.</span>{fila.nombre}
|
||||
</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza1Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza1Porcentaje)}</td>
|
||||
<td className={styles.fuerzaCell}>{fila.fuerza2Display}</td>
|
||||
<td className={styles.porcentajeCell}>{formatPercent(fila.fuerza2Porcentaje)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// src/features/legislativas/nacionales/components/AnimatedNumber.tsx
|
||||
import { useAnimatedNumber } from '../hooks/useAnimatedNumber';
|
||||
|
||||
interface AnimatedNumberProps {
|
||||
value: number;
|
||||
formatter: (value: number) => string;
|
||||
}
|
||||
|
||||
export const AnimatedNumber = ({ value, formatter }: AnimatedNumberProps) => {
|
||||
const animatedValue = useAnimatedNumber(value);
|
||||
return <span>{formatter(animatedValue)}</span>;
|
||||
};
|
||||