Feat Widgets Cards y Optimización de Consultas
This commit is contained in:
@@ -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,46 +1,52 @@
|
||||
// src/components/AgrupacionesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Select from 'react-select'; // Importamos Select
|
||||
import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService';
|
||||
import type { AgrupacionPolitica, LogoAgrupacionCategoria } from '../types';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
// Constantes para los IDs de categoría
|
||||
const SENADORES_ID = 5;
|
||||
const DIPUTADOS_ID = 6;
|
||||
const CONCEJALES_ID = 7;
|
||||
const SENADORES_NAC_ID = 1;
|
||||
const DIPUTADOS_NAC_ID = 2;
|
||||
|
||||
// Opciones para el nuevo selector de Elección
|
||||
const ELECCION_OPTIONS = [
|
||||
{ value: 2, label: 'Elecciones Nacionales' },
|
||||
{ value: 1, label: 'Elecciones Provinciales' }
|
||||
];
|
||||
|
||||
// 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
|
||||
if (!color) return '#000000';
|
||||
const sanitized = color.replace(/[^#0-9a-fA-F]/g, '');
|
||||
return sanitized.startsWith('#') ? sanitized : `#${sanitized}`;
|
||||
};
|
||||
|
||||
export const AgrupacionesManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// --- NUEVO ESTADO PARA LA ELECCIÓN SELECCIONADA ---
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Query 2: Obtener logos
|
||||
// --- CORRECCIÓN: La query de logos ahora depende del ID de la elección ---
|
||||
const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({
|
||||
queryKey: ['logos'],
|
||||
queryFn: getLogos,
|
||||
queryKey: ['logos', selectedEleccion.value],
|
||||
queryFn: () => getLogos(selectedEleccion.value), // Pasamos el valor numérico
|
||||
});
|
||||
|
||||
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, {}]));
|
||||
@@ -48,20 +54,14 @@ export const AgrupacionesManager = () => {
|
||||
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;
|
||||
});
|
||||
}, [agrupaciones]);
|
||||
|
||||
// Este useEffect ahora también depende de 'logos' para reinicializarse
|
||||
useEffect(() => {
|
||||
if (logos) {
|
||||
setEditedLogos(JSON.parse(JSON.stringify(logos)));
|
||||
}
|
||||
// La dependencia ahora es el estado de carga. El hook se ejecutará cuando
|
||||
// isLoadingAgrupaciones o isLoadingLogos cambien de true a false.
|
||||
}, [agrupaciones, logos]);
|
||||
}, [logos]);
|
||||
|
||||
const handleInputChange = (id: string, field: 'nombreCorto' | 'color', value: string) => {
|
||||
setEditedAgrupaciones(prev => ({
|
||||
@@ -74,6 +74,7 @@ export const AgrupacionesManager = () => {
|
||||
setEditedLogos(prev => {
|
||||
const newLogos = [...prev];
|
||||
const existing = newLogos.find(l =>
|
||||
l.eleccionId === selectedEleccion.value &&
|
||||
l.agrupacionPoliticaId === agrupacionId &&
|
||||
l.categoriaId === categoriaId &&
|
||||
l.ambitoGeograficoId == null
|
||||
@@ -84,6 +85,7 @@ export const AgrupacionesManager = () => {
|
||||
} else {
|
||||
newLogos.push({
|
||||
id: 0,
|
||||
eleccionId: selectedEleccion.value, // Añadimos el ID de la elección
|
||||
agrupacionPoliticaId: agrupacionId,
|
||||
categoriaId,
|
||||
logoUrl: value,
|
||||
@@ -99,7 +101,7 @@ export const AgrupacionesManager = () => {
|
||||
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
|
||||
if (original) {
|
||||
return updateAgrupacion(id, { ...original, ...changes });
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export const AgrupacionesManager = () => {
|
||||
await Promise.all([...agrupacionPromises, logoPromise]);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logos'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] }); // Invalidamos la query correcta
|
||||
|
||||
alert('¡Todos los cambios han sido guardados!');
|
||||
} catch (err) {
|
||||
@@ -124,6 +126,7 @@ export const AgrupacionesManager = () => {
|
||||
|
||||
const getLogoUrl = (agrupacionId: string, categoriaId: number) => {
|
||||
return editedLogos.find(l =>
|
||||
l.eleccionId === selectedEleccion.value &&
|
||||
l.agrupacionPoliticaId === agrupacionId &&
|
||||
l.categoriaId === categoriaId &&
|
||||
l.ambitoGeograficoId == null
|
||||
@@ -132,40 +135,67 @@ export const AgrupacionesManager = () => {
|
||||
|
||||
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 Generales</h3>
|
||||
<div style={{width: '250px', 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>
|
||||
{/* --- CABECERAS CONDICIONALES --- */}
|
||||
{selectedEleccion.value === 2 ? (
|
||||
<>
|
||||
<th>Logo Senadores Nac.</th>
|
||||
<th>Logo Diputados Nac.</th>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<th>Logo Senadores Prov.</th>
|
||||
<th>Logo Diputados Prov.</th>
|
||||
<th>Logo Concejales</th>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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" value={sanitizeColor(editedAgrupaciones[agrupacion.id]?.color ?? agrupacion.color)} onChange={(e) => handleInputChange(agrupacion.id, 'color', e.target.value)} />
|
||||
</td>
|
||||
{/* --- CELDAS CONDICIONALES --- */}
|
||||
{selectedEleccion.value === 2 ? (
|
||||
<>
|
||||
<td><input type="text" placeholder="URL..." value={getLogoUrl(agrupacion.id, SENADORES_NAC_ID)} onChange={(e) => handleLogoChange(agrupacion.id, SENADORES_NAC_ID, e.target.value)} /></td>
|
||||
<td><input type="text" placeholder="URL..." value={getLogoUrl(agrupacion.id, DIPUTADOS_NAC_ID)} onChange={(e) => handleLogoChange(agrupacion.id, DIPUTADOS_NAC_ID, e.target.value)} /></td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td><input type="text" placeholder="URL..." value={getLogoUrl(agrupacion.id, SENADORES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, SENADORES_ID, e.target.value)} /></td>
|
||||
<td><input type="text" placeholder="URL..." value={getLogoUrl(agrupacion.id, DIPUTADOS_ID)} onChange={(e) => handleLogoChange(agrupacion.id, DIPUTADOS_ID, e.target.value)} /></td>
|
||||
<td><input type="text" placeholder="URL..." value={getLogoUrl(agrupacion.id, CONCEJALES_ID)} onChange={(e) => handleLogoChange(agrupacion.id, CONCEJALES_ID, e.target.value)} /></td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={handleSaveAll} style={{ marginTop: '1rem' }}>
|
||||
Guardar Todos los Cambios
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// src/components/BancasNacionalesManager.tsx
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getBancadas, getAgrupaciones, updateBancada, type UpdateBancadaData } from '../services/apiService';
|
||||
import type { Bancada, AgrupacionPolitica } from '../types';
|
||||
import { OcupantesModal } from './OcupantesModal';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
const camaras = ['diputados', 'senadores'] as const;
|
||||
|
||||
export const BancasNacionalesManager = () => {
|
||||
const [activeTab, setActiveTab] = useState<'diputados' | 'senadores'>('diputados');
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [bancadaSeleccionada, setBancadaSeleccionada] = useState<Bancada | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones
|
||||
});
|
||||
|
||||
const { data: bancadas = [], isLoading, error } = useQuery<Bancada[]>({
|
||||
queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getBancadas(activeTab, ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
const handleAgrupacionChange = async (bancadaId: number, nuevaAgrupacionId: string | null) => {
|
||||
const bancadaActual = bancadas.find(b => b.id === bancadaId);
|
||||
if (!bancadaActual) return;
|
||||
|
||||
const payload: UpdateBancadaData = {
|
||||
agrupacionPoliticaId: nuevaAgrupacionId,
|
||||
nombreOcupante: nuevaAgrupacionId ? (bancadaActual.ocupante?.nombreOcupante ?? null) : null,
|
||||
fotoUrl: nuevaAgrupacionId ? (bancadaActual.ocupante?.fotoUrl ?? null) : null,
|
||||
periodo: nuevaAgrupacionId ? (bancadaActual.ocupante?.periodo ?? null) : null,
|
||||
};
|
||||
|
||||
try {
|
||||
await updateBancada(bancadaId, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['bancadas', activeTab, ELECCION_ID_NACIONAL] });
|
||||
} catch (err) {
|
||||
alert("Error al guardar el cambio de agrupación.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (bancada: Bancada) => {
|
||||
setBancadaSeleccionada(bancada);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
if (error) return <p style={{ color: 'red' }}>Error al cargar las bancas nacionales.</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Gestión de Bancas (Nacionales)</h3>
|
||||
<p>Asigne partidos y ocupantes a las bancas del Congreso de la Nación.</p>
|
||||
|
||||
<div className="chamber-tabs">
|
||||
{camaras.map(camara => (
|
||||
<button
|
||||
key={camara}
|
||||
className={activeTab === camara ? 'active' : ''}
|
||||
onClick={() => setActiveTab(camara)}
|
||||
>
|
||||
{camara === 'diputados' ? 'Diputados Nacionales (257)' : 'Senadores Nacionales (72)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? <p>Cargando bancas...</p> : (
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '15%' }}>Banca #</th>
|
||||
<th style={{ width: '35%' }}>Partido Asignado</th>
|
||||
<th style={{ width: '30%' }}>Ocupante Actual</th>
|
||||
<th style={{ width: '20%' }}>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bancadas.map((bancada) => (
|
||||
<tr key={bancada.id}>
|
||||
<td>{`${activeTab.charAt(0).toUpperCase()}-${bancada.numeroBanca}`}</td>
|
||||
<td>
|
||||
<select
|
||||
value={bancada.agrupacionPoliticaId || ''}
|
||||
onChange={(e) => handleAgrupacionChange(bancada.id, e.target.value || null)}
|
||||
>
|
||||
<option value="">-- Vacante --</option>
|
||||
{agrupaciones.map(a => <option key={a.id} value={a.id}>{a.nombre}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>{bancada.ocupante?.nombreOcupante || 'Sin asignar'}</td>
|
||||
<td>
|
||||
<button disabled={!bancada.agrupacionPoliticaId} onClick={() => handleOpenModal(bancada)}>
|
||||
Editar Ocupante
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalVisible && bancadaSeleccionada && (
|
||||
<OcupantesModal
|
||||
bancada={bancadaSeleccionada}
|
||||
onClose={() => setModalVisible(false)}
|
||||
activeTab={activeTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
// src/components/BancasPreviasManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getBancasPrevias, updateBancasPrevias, getAgrupaciones } from '../services/apiService';
|
||||
import type { BancaPrevia, AgrupacionPolitica } from '../types';
|
||||
import { TipoCamara } from '../types';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const BancasPreviasManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [editedBancas, setEditedBancas] = useState<Record<string, Partial<BancaPrevia>>>({});
|
||||
|
||||
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
const { data: bancasPrevias = [], isLoading: isLoadingBancas } = useQuery<BancaPrevia[]>({
|
||||
queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getBancasPrevias(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (agrupaciones.length > 0) {
|
||||
const initialData: Record<string, Partial<BancaPrevia>> = {};
|
||||
agrupaciones.forEach(agrupacion => {
|
||||
// Para Diputados
|
||||
const keyDip = `${agrupacion.id}-${TipoCamara.Diputados}`;
|
||||
const existingDip = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Diputados);
|
||||
initialData[keyDip] = { cantidad: existingDip?.cantidad || 0 };
|
||||
|
||||
// Para Senadores
|
||||
const keySen = `${agrupacion.id}-${TipoCamara.Senadores}`;
|
||||
const existingSen = bancasPrevias.find(b => b.agrupacionPoliticaId === agrupacion.id && b.camara === TipoCamara.Senadores);
|
||||
initialData[keySen] = { cantidad: existingSen?.cantidad || 0 };
|
||||
});
|
||||
setEditedBancas(initialData);
|
||||
}
|
||||
}, [agrupaciones, bancasPrevias]);
|
||||
|
||||
const handleInputChange = (agrupacionId: string, camara: typeof TipoCamara.Diputados | typeof TipoCamara.Senadores, value: string) => {
|
||||
const key = `${agrupacionId}-${camara}`;
|
||||
const cantidad = parseInt(value, 10);
|
||||
setEditedBancas(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], cantidad: isNaN(cantidad) ? 0 : cantidad }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload: BancaPrevia[] = Object.entries(editedBancas)
|
||||
.map(([key, value]) => {
|
||||
const [agrupacionPoliticaId, camara] = key.split('-');
|
||||
return {
|
||||
id: 0,
|
||||
eleccionId: ELECCION_ID_NACIONAL,
|
||||
agrupacionPoliticaId,
|
||||
camara: parseInt(camara) as typeof TipoCamara.Diputados | typeof TipoCamara.Senadores,
|
||||
cantidad: value.cantidad || 0,
|
||||
};
|
||||
})
|
||||
.filter(b => b.cantidad > 0);
|
||||
|
||||
try {
|
||||
await updateBancasPrevias(ELECCION_ID_NACIONAL, payload);
|
||||
queryClient.invalidateQueries({ queryKey: ['bancasPrevias', ELECCION_ID_NACIONAL] });
|
||||
alert('Bancas previas guardadas con éxito.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al guardar las bancas previas.');
|
||||
}
|
||||
};
|
||||
|
||||
const totalDiputados = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Diputados}`) ? sum + (value.cantidad || 0) : sum, 0);
|
||||
const totalSenadores = Object.entries(editedBancas).reduce((sum, [key, value]) => key.endsWith(`-${TipoCamara.Senadores}`) ? sum + (value.cantidad || 0) : sum, 0);
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingBancas;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Gestión de Bancas Previas (Composición Nacional)</h3>
|
||||
<p>Define cuántas bancas retiene cada partido antes de la elección. Estos son los escaños que **no** están en juego.</p>
|
||||
{isLoading ? <p>Cargando...</p> : (
|
||||
<>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agrupación Política</th>
|
||||
<th>Bancas Previas Diputados (Total: {totalDiputados} / 130)</th>
|
||||
<th>Bancas Previas Senadores (Total: {totalSenadores} / 48)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>{agrupacion.nombre}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editedBancas[`${agrupacion.id}-${TipoCamara.Diputados}`]?.cantidad || 0}
|
||||
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Diputados, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={editedBancas[`${agrupacion.id}-${TipoCamara.Senadores}`]?.cantidad || 0}
|
||||
onChange={e => handleInputChange(agrupacion.id, TipoCamara.Senadores, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button onClick={handleSave} style={{ marginTop: '1rem' }}>Guardar Bancas Previas</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,16 +84,7 @@ 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 || ''}
|
||||
@@ -102,11 +96,7 @@ export const BancasManager = () => {
|
||||
</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,96 +1,101 @@
|
||||
// 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: 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 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: candidatos = [] } = useQuery<CandidatoOverride[]>({
|
||||
queryKey: ['candidatos', selectedEleccion.value],
|
||||
queryFn: () => getCandidatos(selectedEleccion.value),
|
||||
});
|
||||
|
||||
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
|
||||
const getAmbitoId = () => {
|
||||
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
|
||||
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentCandidato = useMemo(() => {
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
|
||||
// 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 '';
|
||||
|
||||
return candidatos.find(c =>
|
||||
c.ambitoGeograficoId === ambitoIdBuscado &&
|
||||
c.agrupacionPoliticaId === selectedAgrupacion.value &&
|
||||
const ambitoId = getAmbitoId();
|
||||
return candidatos.find(c =>
|
||||
c.ambitoGeograficoId === ambitoId &&
|
||||
c.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
c.categoriaId === selectedCategoria.value
|
||||
)?.nombreCandidato || '';
|
||||
}, [candidatos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setNombreCandidato(currentCandidato) }, [currentCandidato]);
|
||||
}, [candidatos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
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,
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
nombreCandidato: nombreCandidato || null
|
||||
};
|
||||
|
||||
try {
|
||||
await updateCandidatos([newCandidatoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['candidatos'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['candidatos', selectedEleccion.value] });
|
||||
alert('Override de candidato guardado.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('Error al guardar el override del candidato.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Overrides de Nombres de Candidatos</h3>
|
||||
<p>Configure un nombre de candidato específico para un partido, 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..." isDisabled={!selectedEleccion} />
|
||||
<Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedAgrupacion} onChange={setSelectedAgrupacion} placeholder="Seleccione Agrupación..." />
|
||||
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
|
||||
|
||||
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
|
||||
) : <div />}
|
||||
|
||||
{selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
|
||||
) : <div />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>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,110 @@
|
||||
// src/components/ConfiguracionNacional.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { getAgrupaciones, getConfiguracion, updateConfiguracion } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
export const ConfiguracionNacional = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [agrupaciones, setAgrupaciones] = useState<AgrupacionPolitica[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [presidenciaDiputadosId, setPresidenciaDiputadosId] = useState<string>('');
|
||||
const [presidenciaSenadoId, setPresidenciaSenadoId] = useState<string>('');
|
||||
const [modoOficialActivo, setModoOficialActivo] = useState(false);
|
||||
const [diputadosTipoBanca, setDiputadosTipoBanca] = useState<'ganada' | 'previa'>('ganada');
|
||||
// El estado para el tipo de banca del senado ya no es necesario para la UI,
|
||||
// pero lo mantenemos para no romper el handleSave.
|
||||
const [senadoTipoBanca, setSenadoTipoBanca] = useState<'ganada' | 'previa'>('ganada');
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [agrupacionesData, configData] = await Promise.all([getAgrupaciones(), getConfiguracion()]);
|
||||
setAgrupaciones(agrupacionesData);
|
||||
setPresidenciaDiputadosId(configData.PresidenciaDiputadosNacional || '');
|
||||
setPresidenciaSenadoId(configData.PresidenciaSenadoNacional || '');
|
||||
setModoOficialActivo(configData.UsarDatosOficialesNacionales === 'true');
|
||||
setDiputadosTipoBanca(configData.PresidenciaDiputadosNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
|
||||
setSenadoTipoBanca(configData.PresidenciaSenadoNacional_TipoBanca === 'previa' ? 'previa' : 'ganada');
|
||||
} catch (err) { console.error("Error al cargar datos de configuración nacional:", err); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
loadInitialData();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateConfiguracion({
|
||||
"PresidenciaDiputadosNacional": presidenciaDiputadosId,
|
||||
"PresidenciaSenadoNacional": presidenciaSenadoId,
|
||||
"UsarDatosOficialesNacionales": modoOficialActivo.toString(),
|
||||
"PresidenciaDiputadosNacional_TipoBanca": diputadosTipoBanca,
|
||||
// Aunque no se muestre, guardamos el valor para consistencia
|
||||
"PresidenciaSenadoNacional_TipoBanca": senadoTipoBanca,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['composicionNacional'] });
|
||||
alert('Configuración nacional guardada.');
|
||||
} catch { alert('Error al guardar.'); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="admin-module"><p>Cargando...</p></div>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Configuración de Widgets Nacionales</h3>
|
||||
{/*<div className="form-group">
|
||||
<label>
|
||||
<input type="checkbox" checked={modoOficialActivo} onChange={e => setModoOficialActivo(e.target.checked)} />
|
||||
**Activar Modo "Resultados Oficiales" para Widgets Nacionales**
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666' }}>
|
||||
Si está activo, los widgets nacionales usarán la composición manual de bancas. Si no, usarán la proyección en tiempo real.
|
||||
</p>
|
||||
</div>*/}
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginTop: '1rem' }}>
|
||||
{/* Columna Diputados */}
|
||||
<div style={{ flex: 1, borderRight: '1px solid #ccc', paddingRight: '1rem' }}>
|
||||
<label htmlFor="presidencia-diputados-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
Presidencia Cámara de Diputados
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
|
||||
Este escaño es parte de los 257 diputados y se descuenta del total del partido.
|
||||
</p>
|
||||
<select id="presidencia-diputados-nacional" value={presidenciaDiputadosId} onChange={e => setPresidenciaDiputadosId(e.target.value)} style={{ width: '100%', padding: '8px', marginBottom: '0.5rem' }}>
|
||||
<option value="">-- No Asignado --</option>
|
||||
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{a.nombre}</option>))}
|
||||
</select>
|
||||
{presidenciaDiputadosId && (
|
||||
<div>
|
||||
<label><input type="radio" value="ganada" checked={diputadosTipoBanca === 'ganada'} onChange={() => setDiputadosTipoBanca('ganada')} /> Descontar de Banca Ganada</label>
|
||||
<label style={{marginLeft: '1rem'}}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columna Senadores */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="presidencia-senado-nacional" style={{ display: 'block', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
Presidencia Senado (Vicepresidente)
|
||||
</label>
|
||||
<p style={{ fontSize: '0.8rem', color: '#666', margin: '0.5rem 0 0 0' }}>
|
||||
Este escaño es adicional a los 72 senadores y no se descuenta del total del partido.
|
||||
</p>
|
||||
<select id="presidencia-senado-nacional" value={presidenciaSenadoId} onChange={e => setPresidenciaSenadoId(e.target.value)} style={{ width: '100%', padding: '8px' }}>
|
||||
<option value="">-- No Asignado --</option>
|
||||
{agrupaciones.map(a => (<option key={a.id} value={a.id}>{a.nombre}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleSave} style={{ marginTop: '1.5rem' }}>
|
||||
Guardar Configuración
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -2,83 +2,104 @@
|
||||
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: 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 ---
|
||||
// --- ESTADOS ---
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
|
||||
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
|
||||
const [selectedMunicipio, setSelectedMunicipio] = useState<MunicipioSimple | null>(null);
|
||||
const [selectedCategoria, setSelectedCategoria] = useState<{ value: number; label: string } | null>(null);
|
||||
const [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]);
|
||||
// --- QUERIES ---
|
||||
const { data: provincias = [] } = useQuery<ProvinciaSimple[]>({ queryKey: ['provinciasForAdmin'], queryFn: getProvinciasForAdmin });
|
||||
const { data: municipios = [] } = useQuery<MunicipioSimple[]>({ queryKey: ['municipiosForAdmin'], queryFn: getMunicipiosForAdmin });
|
||||
const { data: agrupaciones = [] } = useQuery<AgrupacionPolitica[]>({ queryKey: ['agrupaciones'], queryFn: getAgrupaciones });
|
||||
const { data: logos = [] } = useQuery<LogoAgrupacionCategoria[]>({
|
||||
queryKey: ['logos', selectedEleccion.value],
|
||||
queryFn: () => getLogos(selectedEleccion.value)
|
||||
});
|
||||
|
||||
// --- LÓGICA DE SELECTORES DINÁMICOS ---
|
||||
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
|
||||
const getAmbitoId = () => {
|
||||
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
|
||||
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentLogo = useMemo(() => {
|
||||
// La búsqueda ahora depende de los 3 selectores
|
||||
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return '';
|
||||
return logos.find(l =>
|
||||
l.ambitoGeograficoId === parseInt(selectedMunicipio.value) &&
|
||||
l.agrupacionPoliticaId === selectedAgrupacion.value &&
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
const ambitoId = getAmbitoId();
|
||||
return logos.find(l =>
|
||||
l.ambitoGeograficoId === ambitoId &&
|
||||
l.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
l.categoriaId === selectedCategoria.value
|
||||
)?.logoUrl || '';
|
||||
}, [logos, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setLogoUrl(currentLogo) }, [currentLogo]);
|
||||
}, [logos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMunicipio || !selectedAgrupacion || !selectedCategoria) return;
|
||||
if (!selectedAgrupacion || !selectedCategoria) return;
|
||||
const newLogoEntry: LogoAgrupacionCategoria = {
|
||||
id: 0,
|
||||
agrupacionPoliticaId: selectedAgrupacion.value,
|
||||
eleccionId: selectedEleccion.value,
|
||||
agrupacionPoliticaId: selectedAgrupacion.id,
|
||||
categoriaId: selectedCategoria.value,
|
||||
ambitoGeograficoId: parseInt(selectedMunicipio.value),
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
logoUrl: logoUrl || null
|
||||
};
|
||||
try {
|
||||
await updateLogos([newLogoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['logos'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] });
|
||||
alert('Override de logo guardado.');
|
||||
} catch { alert('Error al guardar.'); }
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Overrides de Logos 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..." isDisabled={!selectedEleccion} />
|
||||
<Select options={agrupaciones.map(a => ({ value: a.id, label: a.nombre, ...a }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedAgrupacion} onChange={setSelectedAgrupacion} placeholder="Seleccione Agrupación..." />
|
||||
<Select options={AMBITO_LEVEL_OPTIONS} value={selectedAmbitoLevel} onChange={(opt) => { setSelectedAmbitoLevel(opt!); setSelectedProvincia(null); setSelectedMunicipio(null); }} />
|
||||
|
||||
{selectedAmbitoLevel.value === 'provincia' || selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={provincias.map(p => ({ value: p.id, label: p.nombre, ...p }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedProvincia} onChange={setSelectedProvincia} placeholder="Seleccione Provincia..." />
|
||||
) : <div />}
|
||||
|
||||
{selectedAmbitoLevel.value === 'municipio' ? (
|
||||
<Select options={municipios.map(m => ({ value: m.id, label: m.nombre, ...m }))} getOptionValue={opt => opt.id} getOptionLabel={opt => opt.nombre} value={selectedMunicipio} onChange={setSelectedMunicipio} placeholder="Seleccione Municipio..." isDisabled={!selectedProvincia} />
|
||||
) : <div />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-end', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label>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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// src/components/OrdenDiputadosNacionalesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import { SortableItem } from './SortableItem';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const OrdenDiputadosNacionalesManager = () => {
|
||||
// Estado para la lista que el usuario puede ordenar
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
// Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.)
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
// Query 2: Obtener los datos de composición para saber qué partidos tienen bancas
|
||||
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
|
||||
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
// Este efecto se ejecuta cuando los datos de las queries estén disponibles
|
||||
useEffect(() => {
|
||||
// No hacemos nada hasta que ambas queries hayan cargado sus datos
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de diputado
|
||||
const partidosConBancasIds = new Set(
|
||||
composicionData.diputados.partidos
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map(p => p.id)
|
||||
);
|
||||
|
||||
// Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
// Ordenamos la lista filtrada según el orden guardado en la BD
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999));
|
||||
|
||||
// Actualizamos el estado que se renderiza y que el usuario puede ordenar
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
}, [todasAgrupaciones, composicionData]); // Dependencias: se re-ejecuta si los datos cambian
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupacionesOrdenadas((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
|
||||
try {
|
||||
await updateOrden('diputados-nacionales', idsOrdenados);
|
||||
alert('Orden de Diputados Nacionales guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Diputados Nacionales.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Diputados Nacionales)</h3>
|
||||
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupacionesOrdenadas.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{agrupacion.nombreCorto || agrupacion.nombre}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
// src/components/OrdenSenadoresNacionalesManager.tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { getAgrupaciones, updateOrden, getComposicionNacional } from '../services/apiService';
|
||||
import type { AgrupacionPolitica } from '../types';
|
||||
import { SortableItem } from './SortableItem';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const ELECCION_ID_NACIONAL = 2;
|
||||
|
||||
export const OrdenSenadoresNacionalesManager = () => {
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
const { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
|
||||
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Creamos un Set con los IDs de los partidos que tienen al menos una banca de senador
|
||||
const partidosConBancasIds = new Set(
|
||||
composicionData.senadores.partidos
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map(p => p.id)
|
||||
);
|
||||
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999));
|
||||
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
}, [todasAgrupaciones, composicionData]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setAgrupacionesOrdenadas((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
const idsOrdenados = agrupacionesOrdenadas.map(a => a.id);
|
||||
try {
|
||||
await updateOrden('senadores-nacionales', idsOrdenados);
|
||||
alert('Orden de Senadores Nacionales guardado con éxito!');
|
||||
} catch (error) {
|
||||
alert('Error al guardar el orden de Senadores Nacionales.');
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>;
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<h3>Ordenar Agrupaciones (Senado de la Nación)</h3>
|
||||
<p>Arrastre para reordenar. Solo se muestran los partidos con bancas.</p>
|
||||
<p>Ancla izquierda. Prioridad de izquierda a derecha y de arriba abajo.</p>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={agrupacionesOrdenadas.map(a => a.id)} strategy={horizontalListSortingStrategy}>
|
||||
<ul className="sortable-list-horizontal">
|
||||
{agrupacionesOrdenadas.map(agrupacion => (
|
||||
<SortableItem key={agrupacion.id} id={agrupacion.id}>
|
||||
{agrupacion.nombreCorto || agrupacion.nombre}
|
||||
</SortableItem>
|
||||
))}
|
||||
</ul>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button onClick={handleSaveOrder} style={{ marginTop: '1rem' }}>Guardar Orden</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
Elecciones-Web/frontend-admin/src/constants/categorias.ts
Normal file
23
Elecciones-Web/frontend-admin/src/constants/categorias.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// src/constants/categorias.ts
|
||||
|
||||
// Opciones para los selectores en el panel de administración
|
||||
export const CATEGORIAS_ADMIN_OPTIONS = [
|
||||
// Nacionales
|
||||
{ value: 1, label: 'Senadores Nacionales' },
|
||||
{ value: 2, label: 'Diputados Nacionales' },
|
||||
// Provinciales
|
||||
{ value: 5, label: 'Senadores Provinciales' },
|
||||
{ value: 6, label: 'Diputados Provinciales' },
|
||||
{ value: 7, label: 'Concejales' },
|
||||
];
|
||||
|
||||
export const CATEGORIAS_NACIONALES_OPTIONS = [
|
||||
{ value: 1, label: 'Senadores Nacionales' },
|
||||
{ value: 2, label: 'Diputados Nacionales' },
|
||||
];
|
||||
|
||||
export const CATEGORIAS_PROVINCIALES_OPTIONS = [
|
||||
{ value: 5, label: 'Senadores Provinciales' },
|
||||
{ value: 6, label: 'Diputados Provinciales' },
|
||||
{ value: 7, label: 'Concejales' },
|
||||
];
|
||||
@@ -1,11 +1,12 @@
|
||||
// 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 +22,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 +46,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 +57,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 +98,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 +109,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 +143,52 @@ 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;
|
||||
};
|
||||
@@ -8,6 +8,9 @@ export interface AgrupacionPolitica {
|
||||
color: string | null;
|
||||
ordenDiputados: number | null;
|
||||
ordenSenadores: number | null;
|
||||
// Añadimos los nuevos campos para el ordenamiento nacional
|
||||
ordenDiputadosNacionales: number | null;
|
||||
ordenSenadoresNacionales: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgrupacionData {
|
||||
@@ -30,9 +33,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,8 +43,20 @@ 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;
|
||||
logoUrl: string | null;
|
||||
@@ -50,8 +65,11 @@ export interface LogoAgrupacionCategoria {
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user