Feat Widgets

- Widget de Home
- Widget Cards por Provincias
- Widget Mapa por Categorias
This commit is contained in:
2025-10-01 10:03:01 -03:00
parent 3b0eee25e6
commit a985cbfd7c
45 changed files with 1786 additions and 953 deletions

View File

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

View File

@@ -7,6 +7,7 @@ import type { MunicipioSimple, AgrupacionPolitica, LogoAgrupacionCategoria, Prov
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
const ELECCION_OPTIONS = [
{ value: 0, label: 'General (Toda la elección)' },
{ value: 2, label: 'Elecciones Nacionales' },
{ value: 1, label: 'Elecciones Provinciales' }
];
@@ -44,7 +45,7 @@ export const LogoOverridesManager = () => {
const getAmbitoId = () => {
if (selectedAmbitoLevel.value === 'municipio' && selectedMunicipio) return parseInt(selectedMunicipio.id);
if (selectedAmbitoLevel.value === 'provincia' && selectedProvincia) return parseInt(selectedProvincia.id);
return null;
return 0;
};
const currentLogo = useMemo(() => {

View File

@@ -8,7 +8,6 @@ export interface AgrupacionPolitica {
color: string | null;
ordenDiputados: number | null;
ordenSenadores: number | null;
// Añadimos los nuevos campos para el ordenamiento nacional
ordenDiputadosNacionales: number | null;
ordenSenadoresNacionales: number | null;
}
@@ -58,7 +57,7 @@ export interface LogoAgrupacionCategoria {
id: number;
eleccionId: number; // Clave para diferenciar
agrupacionPoliticaId: string;
categoriaId: number;
categoriaId: number | null;
logoUrl: string | null;
ambitoGeograficoId: number | null;
}