Compare commits
60 Commits
63cc042eb4
...
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 |
1
.gitignore
vendored
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>
|
||||
);
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import Select from 'react-select';
|
||||
import { getAgrupaciones, updateAgrupacion, getLogos, updateLogos } from '../services/apiService';
|
||||
import type { AgrupacionPolitica, LogoAgrupacionCategoria, UpdateAgrupacionData } from '../types';
|
||||
import { AddAgrupacionForm } from './AddAgrupacionForm';
|
||||
import './AgrupacionesManager.css';
|
||||
|
||||
const GLOBAL_ELECTION_ID = 0;
|
||||
@@ -28,12 +29,17 @@ export const AgrupacionesManager = () => {
|
||||
const { data: agrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'], queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
|
||||
const { data: logos = [], isLoading: isLoadingLogos } = useQuery<LogoAgrupacionCategoria[]>({
|
||||
queryKey: ['allLogos'],
|
||||
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
const handleCreationSuccess = () => {
|
||||
// Invalida la query de agrupaciones para forzar una actualización
|
||||
queryClient.invalidateQueries({ queryKey: ['agrupaciones'] });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (agrupaciones.length > 0) {
|
||||
const initialEdits = Object.fromEntries(
|
||||
@@ -63,7 +69,7 @@ export const AgrupacionesManager = () => {
|
||||
const key = `${agrupacionId}-${selectedEleccion.value}`;
|
||||
setEditedLogos(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
const agrupacionPromises = agrupaciones.map(agrupacion => {
|
||||
@@ -74,7 +80,7 @@ export const AgrupacionesManager = () => {
|
||||
};
|
||||
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]) => {
|
||||
@@ -85,13 +91,13 @@ export const AgrupacionesManager = () => {
|
||||
const logoPromise = updateLogos(logosPayload);
|
||||
|
||||
await Promise.all([...agrupacionPromises, logoPromise]);
|
||||
|
||||
|
||||
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."); }
|
||||
};
|
||||
|
||||
|
||||
const getLogoValue = (agrupacionId: string): string => {
|
||||
const key = `${agrupacionId}-${selectedEleccion.value}`;
|
||||
return editedLogos[key] ?? '';
|
||||
@@ -101,9 +107,9 @@ export const AgrupacionesManager = () => {
|
||||
|
||||
return (
|
||||
<div className="admin-module">
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3>Gestión de Agrupaciones y Logos</h3>
|
||||
<div style={{width: '350px', zIndex: 100 }}>
|
||||
<div style={{ width: '350px', zIndex: 100 }}>
|
||||
<Select options={ELECCION_OPTIONS} value={selectedEleccion} onChange={(opt) => setSelectedEleccion(opt!)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,15 +129,15 @@ export const AgrupacionesManager = () => {
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>{agrupacion.nombre}</td>
|
||||
<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)}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="URL..."
|
||||
value={getLogoValue(agrupacion.id)}
|
||||
onChange={(e) => handleLogoInputChange(agrupacion.id, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -142,6 +148,7 @@ export const AgrupacionesManager = () => {
|
||||
<button onClick={handleSaveAll} style={{ marginTop: '1rem' }}>
|
||||
Guardar Todos los Cambios
|
||||
</button>
|
||||
<AddAgrupacionForm onSuccess={handleCreationSuccess} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const BancasNacionalesManager = () => {
|
||||
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>
|
||||
|
||||
@@ -95,7 +95,7 @@ export const BancasPreviasManager = () => {
|
||||
<tbody>
|
||||
{agrupaciones.map(agrupacion => (
|
||||
<tr key={agrupacion.id}>
|
||||
<td>{agrupacion.nombre}</td>
|
||||
<td>({agrupacion.id}) {agrupacion.nombre}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -91,7 +91,7 @@ export const BancasProvincialesManager = () => {
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { getProvinciasForAdmin, getMunicipiosForAdmin, getAgrupaciones, getCandi
|
||||
import type { MunicipioSimple, AgrupacionPolitica, CandidatoOverride, ProvinciaSimple } from '../types';
|
||||
import { CATEGORIAS_NACIONALES_OPTIONS, CATEGORIAS_PROVINCIALES_OPTIONS } from '../constants/categorias';
|
||||
|
||||
const ELECCION_OPTIONS = [
|
||||
const ELECCION_OPTIONS = [
|
||||
{ value: 0, label: 'General (Todas las elecciones)' },
|
||||
{ value: 2, label: 'Elecciones Nacionales' },
|
||||
{ value: 1, label: 'Elecciones Provinciales' }
|
||||
];
|
||||
@@ -31,12 +32,17 @@ export const CandidatoOverridesManager = () => {
|
||||
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),
|
||||
queryKey: ['allCandidatos'],
|
||||
queryFn: () => Promise.all([getCandidatos(0), getCandidatos(1), getCandidatos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
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);
|
||||
@@ -48,11 +54,12 @@ export const CandidatoOverridesManager = () => {
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
const ambitoId = getAmbitoId();
|
||||
return candidatos.find(c =>
|
||||
c.eleccionId === selectedEleccion.value &&
|
||||
c.ambitoGeograficoId === ambitoId &&
|
||||
c.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
c.categoriaId === selectedCategoria.value
|
||||
)?.nombreCandidato || '';
|
||||
}, [candidatos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
}, [candidatos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setNombreCandidato(currentCandidato || ''); }, [currentCandidato]);
|
||||
|
||||
@@ -64,11 +71,11 @@ export const CandidatoOverridesManager = () => {
|
||||
agrupacionPoliticaId: selectedAgrupacion.id,
|
||||
categoriaId: selectedCategoria.value,
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
nombreCandidato: nombreCandidato || null
|
||||
nombreCandidato: nombreCandidato.trim() || null
|
||||
};
|
||||
try {
|
||||
await updateCandidatos([newCandidatoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['candidatos', selectedEleccion.value] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allCandidatos'] });
|
||||
alert('Override de candidato guardado.');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -82,8 +89,15 @@ export const CandidatoOverridesManager = () => {
|
||||
<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={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' ? (
|
||||
|
||||
@@ -9,7 +9,7 @@ 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);
|
||||
@@ -30,7 +30,7 @@ export const ConfiguracionNacional = () => {
|
||||
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); }
|
||||
} catch (err) { console.error("Error al cargar datos de configuración nacional:", err); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
loadInitialData();
|
||||
@@ -56,16 +56,7 @@ export const ConfiguracionNacional = () => {
|
||||
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' }}>
|
||||
@@ -77,14 +68,14 @@ export const ConfiguracionNacional = () => {
|
||||
</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>))}
|
||||
{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>
|
||||
<label style={{ marginLeft: '1rem' }}><input type="radio" value="previa" checked={diputadosTipoBanca === 'previa'} onChange={() => setDiputadosTipoBanca('previa')} /> Descontar de Banca Previa</label>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Columna Senadores */}
|
||||
@@ -97,11 +88,11 @@ export const ConfiguracionNacional = () => {
|
||||
</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>
|
||||
{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>
|
||||
|
||||
72
Elecciones-Web/frontend-admin/src/components/FormStyles.css
Normal file
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;
|
||||
}
|
||||
@@ -7,7 +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: 0, label: 'General (Todas las elecciones)' },
|
||||
{ value: 2, label: 'Elecciones Nacionales' },
|
||||
{ value: 1, label: 'Elecciones Provinciales' }
|
||||
];
|
||||
@@ -21,7 +21,6 @@ const AMBITO_LEVEL_OPTIONS = [
|
||||
export const LogoOverridesManager = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// --- ESTADOS ---
|
||||
const [selectedEleccion, setSelectedEleccion] = useState(ELECCION_OPTIONS[0]);
|
||||
const [selectedAmbitoLevel, setSelectedAmbitoLevel] = useState(AMBITO_LEVEL_OPTIONS[0]);
|
||||
const [selectedProvincia, setSelectedProvincia] = useState<ProvinciaSimple | null>(null);
|
||||
@@ -30,49 +29,57 @@ export const LogoOverridesManager = () => {
|
||||
const [selectedAgrupacion, setSelectedAgrupacion] = useState<AgrupacionPolitica | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
|
||||
// --- 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)
|
||||
queryKey: ['allLogos'],
|
||||
queryFn: () => Promise.all([getLogos(0), getLogos(1), getLogos(2)]).then(res => res.flat()),
|
||||
});
|
||||
|
||||
// --- LÓGICA DE SELECTORES DINÁMICOS ---
|
||||
const categoriaOptions = selectedEleccion.value === 2 ? CATEGORIAS_NACIONALES_OPTIONS : CATEGORIAS_PROVINCIALES_OPTIONS;
|
||||
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 0;
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentLogo = useMemo(() => {
|
||||
if (!selectedAgrupacion || !selectedCategoria) return '';
|
||||
const ambitoId = getAmbitoId();
|
||||
|
||||
return logos.find(l =>
|
||||
l.eleccionId === selectedEleccion.value &&
|
||||
l.ambitoGeograficoId === ambitoId &&
|
||||
l.agrupacionPoliticaId === selectedAgrupacion.id &&
|
||||
l.categoriaId === selectedCategoria.value
|
||||
)?.logoUrl || '';
|
||||
}, [logos, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
}, [logos, selectedEleccion, selectedAmbitoLevel, selectedProvincia, selectedMunicipio, selectedAgrupacion, selectedCategoria]);
|
||||
|
||||
useEffect(() => { setLogoUrl(currentLogo || ''); }, [currentLogo]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedAgrupacion || !selectedCategoria) return;
|
||||
if (!selectedAgrupacion || !selectedCategoria) {
|
||||
alert("Por favor, seleccione una agrupación y una categoría.");
|
||||
return;
|
||||
}
|
||||
const newLogoEntry: LogoAgrupacionCategoria = {
|
||||
id: 0,
|
||||
eleccionId: selectedEleccion.value,
|
||||
agrupacionPoliticaId: selectedAgrupacion.id,
|
||||
categoriaId: selectedCategoria.value,
|
||||
ambitoGeograficoId: getAmbitoId(),
|
||||
logoUrl: logoUrl || null
|
||||
logoUrl: logoUrl.trim() || null
|
||||
};
|
||||
try {
|
||||
await updateLogos([newLogoEntry]);
|
||||
queryClient.invalidateQueries({ queryKey: ['logos', selectedEleccion.value] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allLogos'] });
|
||||
alert('Override de logo guardado.');
|
||||
} catch { alert('Error al guardar.'); }
|
||||
};
|
||||
@@ -83,8 +90,15 @@ export const LogoOverridesManager = () => {
|
||||
<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={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' ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -11,92 +11,92 @@ 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),
|
||||
});
|
||||
// Estado para la lista que el usuario puede ordenar
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Query 1: Obtener TODAS las agrupaciones para tener sus datos completos (nombre, etc.)
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
// 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)
|
||||
);
|
||||
// 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),
|
||||
});
|
||||
|
||||
// Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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 })
|
||||
// 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)
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
// Filtramos la lista completa de agrupaciones, quedándonos solo con las relevantes
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
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.');
|
||||
}
|
||||
};
|
||||
// Ordenamos la lista filtrada según el orden guardado en la BD
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenDiputadosNacionales || 999) - (b.ordenDiputadosNacionales || 999));
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Diputados Nacionales...</p>;
|
||||
// Actualizamos el estado que se renderiza y que el usuario puede ordenar
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
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>
|
||||
);
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
@@ -11,84 +11,84 @@ 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),
|
||||
});
|
||||
const [agrupacionesOrdenadas, setAgrupacionesOrdenadas] = useState<AgrupacionPolitica[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { data: todasAgrupaciones = [], isLoading: isLoadingAgrupaciones } = useQuery<AgrupacionPolitica[]>({
|
||||
queryKey: ['agrupaciones'],
|
||||
queryFn: getAgrupaciones,
|
||||
});
|
||||
|
||||
// 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 { data: composicionData, isLoading: isLoadingComposicion } = useQuery({
|
||||
queryKey: ['composicionNacional', ELECCION_ID_NACIONAL],
|
||||
queryFn: () => getComposicionNacional(ELECCION_ID_NACIONAL),
|
||||
});
|
||||
|
||||
const agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
useEffect(() => {
|
||||
if (!composicionData || !todasAgrupaciones || todasAgrupaciones.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999));
|
||||
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
}, [todasAgrupaciones, composicionData]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
// 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 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 agrupacionesFiltradas = todasAgrupaciones.filter(a => partidosConBancasIds.has(a.id));
|
||||
|
||||
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.');
|
||||
}
|
||||
};
|
||||
agrupacionesFiltradas.sort((a, b) => (a.ordenSenadoresNacionales || 999) - (b.ordenSenadoresNacionales || 999));
|
||||
|
||||
const isLoading = isLoadingAgrupaciones || isLoadingComposicion;
|
||||
if (isLoading) return <p>Cargando orden de Senadores Nacionales...</p>;
|
||||
setAgrupacionesOrdenadas(agrupacionesFiltradas);
|
||||
|
||||
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>
|
||||
);
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
// 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' },
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
// Provinciales
|
||||
{ value: 5, label: 'Senadores Provinciales' },
|
||||
{ value: 6, label: 'Diputados Provinciales' },
|
||||
@@ -12,8 +12,8 @@ export const CATEGORIAS_ADMIN_OPTIONS = [
|
||||
];
|
||||
|
||||
export const CATEGORIAS_NACIONALES_OPTIONS = [
|
||||
{ value: 1, label: 'Senadores Nacionales' },
|
||||
{ value: 2, label: 'Diputados Nacionales' },
|
||||
{ value: 2, label: 'Senadores Nacionales' },
|
||||
{ value: 3, label: 'Diputados Nacionales' },
|
||||
];
|
||||
|
||||
export const CATEGORIAS_PROVINCIALES_OPTIONS = [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// src/services/apiService.ts
|
||||
import axios from 'axios';
|
||||
import { triggerLogout } from '../context/authUtils';
|
||||
import type { CandidatoOverride, AgrupacionPolitica,
|
||||
import type {
|
||||
CandidatoOverride, AgrupacionPolitica,
|
||||
UpdateAgrupacionData, Bancada, LogoAgrupacionCategoria,
|
||||
MunicipioSimple, BancaPrevia, ProvinciaSimple } from '../types';
|
||||
MunicipioSimple, BancaPrevia, ProvinciaSimple
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* URL base para las llamadas a la API.
|
||||
@@ -29,8 +31,8 @@ const adminApiClient = axios.create({
|
||||
|
||||
// Cliente de API para endpoints públicos (no envía token)
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
baseURL: API_URL_BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
|
||||
@@ -60,26 +62,26 @@ 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;
|
||||
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;
|
||||
camaraNombre: string;
|
||||
totalBancas: number;
|
||||
bancasEnJuego: number;
|
||||
partidos: PartidoComposicionNacional[];
|
||||
presidenteBancada: { color: string | null; tipoBanca: 'ganada' | 'previa' | null } | null;
|
||||
}
|
||||
export interface ComposicionNacionalData {
|
||||
diputados: CamaraComposicionNacional;
|
||||
senadores: CamaraComposicionNacional;
|
||||
diputados: CamaraComposicionNacional;
|
||||
senadores: CamaraComposicionNacional;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,22 +175,34 @@ export const updateLoggingLevel = async (data: UpdateLoggingLevelData): Promise<
|
||||
|
||||
// 9. Bancas Previas
|
||||
export const getBancasPrevias = async (eleccionId: number): Promise<BancaPrevia[]> => {
|
||||
const response = await adminApiClient.get(`/bancas-previas/${eleccionId}`);
|
||||
return response.data;
|
||||
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);
|
||||
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;
|
||||
// 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;
|
||||
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;
|
||||
};
|
||||
156
Elecciones-Web/frontend/public/bootstrap.js
vendored
156
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 });
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 821 B After Width: | Height: | Size: 821 B |
@@ -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>
|
||||
</>
|
||||
</>*/}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
ApiResponseTablaDetallada, ProyeccionBancas, MunicipioSimple,
|
||||
TelegramaData, CatalogoItem, CategoriaResumen, ResultadoTicker,
|
||||
ApiResponseResultadosPorSeccion, PanelElectoralDto, ResumenProvincia,
|
||||
CategoriaResumenHome
|
||||
CategoriaResumenHome, ResultadoFila, ResultadoSeccion,
|
||||
ProvinciaResumen
|
||||
} from './types/types';
|
||||
|
||||
/**
|
||||
@@ -246,18 +247,42 @@ export const getEstablecimientosPorMunicipio = async (municipioId: string): Prom
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPanelElectoral = async (eleccionId: number, ambitoId: string | null, categoriaId: number): Promise<PanelElectoralDto> => {
|
||||
export const getPanelElectoral = async (
|
||||
eleccionId: number,
|
||||
ambitoId: string | null,
|
||||
categoriaId: number,
|
||||
nivel: 'pais' | 'provincia' | 'municipio'
|
||||
): Promise<PanelElectoralDto> => {
|
||||
|
||||
// Construimos la URL base
|
||||
let url = ambitoId
|
||||
? `/elecciones/${eleccionId}/panel/${ambitoId}`
|
||||
: `/elecciones/${eleccionId}/panel`;
|
||||
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}`;
|
||||
}
|
||||
|
||||
// Añadimos categoriaId como un query parameter
|
||||
url += `?categoriaId=${categoriaId}`;
|
||||
|
||||
const { data } = await apiClient.get(url);
|
||||
return data;
|
||||
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> => {
|
||||
@@ -289,13 +314,48 @@ export const getResumenPorProvincia = async (eleccionId: number, params: Resumen
|
||||
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;
|
||||
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;
|
||||
};
|
||||
@@ -5,6 +5,11 @@ 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 }) => {
|
||||
@@ -27,7 +32,7 @@ export const DevAppLegislativas = () => {
|
||||
const sectionStyle = {
|
||||
border: '2px solid #007bff',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem 2rem',
|
||||
padding: '2px',
|
||||
marginTop: '3rem',
|
||||
marginBottom: '3rem',
|
||||
backgroundColor: '#f8f9fa'
|
||||
@@ -45,26 +50,81 @@ export const DevAppLegislativas = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="container-legislativas2025">
|
||||
<h1>Visor de Widgets</h1>
|
||||
|
||||
<CongresoNacionalWidget eleccionId={2} />
|
||||
<PanelNacionalWidget eleccionId={2} />
|
||||
|
||||
<div style={sectionStyle}>
|
||||
<h2>Widget: Carrusel de Resultados (Home)</h2>
|
||||
<h2>Widget: Carrusel de Resultados Provincias (Home)</h2>
|
||||
<p style={descriptionStyle}>
|
||||
Uso: <code style={codeStyle}><HomeCarouselWidget eleccionId={2} distritoId="02" categoriaId={2} titulo="Diputados - Provincia de Buenos Aires" /></code>
|
||||
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={2} // Diputados Nacionales
|
||||
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>
|
||||
@@ -86,7 +146,7 @@ export const DevAppLegislativas = () => {
|
||||
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>
|
||||
@@ -98,10 +158,10 @@ export const DevAppLegislativas = () => {
|
||||
<p style={descriptionStyle}>
|
||||
Muestra todas las provincias que votan para una categoría específica.
|
||||
<br />
|
||||
Ejemplo Senadores (ID 1): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={1} /></code>
|
||||
Ejemplo Senadores (ID 2): <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={1} />
|
||||
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoCategoriaId={2} />
|
||||
|
||||
<hr style={{ marginTop: '2rem' }} />
|
||||
|
||||
<h3 style={{ marginTop: '2rem' }}>4. Indicando Cantidad de Resultados (cantidadResultados)</h3>
|
||||
@@ -132,16 +192,24 @@ export const DevAppLegislativas = () => {
|
||||
<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={1} cantidadResultados={1} /></code>
|
||||
Uso: <code style={codeStyle}><ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={2} cantidadResultados={1} /></code>
|
||||
</p>
|
||||
<ResultadosNacionalesCardsWidget eleccionId={2} focoDistritoId="16" focoCategoriaId={1} cantidadResultados={1} />
|
||||
<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>
|
||||
|
||||
|
||||
{/* --- OTROS WIDGETS --- */}
|
||||
<CongresoNacionalWidget eleccionId={2} />
|
||||
<PanelNacionalWidget eleccionId={2} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
.container{
|
||||
.container-legislativas2025{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ 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 '../provinciales/CongresoWidget.css';
|
||||
import styles from './CongresoNacionalWidget.module.css';
|
||||
|
||||
interface CongresoNacionalWidgetProps {
|
||||
eleccionId: number;
|
||||
@@ -29,7 +29,7 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
const { data } = useSuspenseQuery<ComposicionNacionalData>({
|
||||
queryKey: ['composicionNacional', eleccionId],
|
||||
queryFn: () => getComposicionNacional(eleccionId),
|
||||
refetchInterval: 30000,
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
const datosCamaraActual = data[camaraActiva];
|
||||
@@ -71,11 +71,12 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
return adjustedPartyData;
|
||||
}, [partidosOrdenados, datosCamaraActual.presidenteBancada, camaraActiva]);
|
||||
|
||||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||||
return (
|
||||
<div className="congreso-container">
|
||||
<div className="congreso-grafico">
|
||||
<div className={styles.congresoContainer}>
|
||||
<div className={styles.congresoGrafico}>
|
||||
<div
|
||||
className={`congreso-hemiciclo-wrapper ${isHovering ? 'is-hovering' : ''}`}
|
||||
className={`${styles.congresoHemicicloWrapper} ${isHovering ? styles.isHovering : ''}`}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
@@ -92,53 +93,53 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className="congreso-footer">
|
||||
<div className="footer-legend">
|
||||
<div className="footer-legend-item">
|
||||
{/* Usamos la nueva clase CSS para el círculo sólido */}
|
||||
<span className="legend-icon legend-icon--solid"></span>
|
||||
<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="footer-legend-item">
|
||||
{/* Reemplazamos el SVG por un span con la nueva clase para el anillo */}
|
||||
<span className="legend-icon legend-icon--ring"></span>
|
||||
<span>Bancas previas</span>
|
||||
<div className={styles.footerLegendItem}>
|
||||
<span className={`${styles.legendIcon} ${styles.legendIconRing}`}></span>
|
||||
<span>Bancas Fijas</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-timestamp">
|
||||
<div className={styles.footerTimestamp}>
|
||||
Última Actualización: {formatTimestamp(datosCamaraActual.ultimaActualizacion)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="congreso-summary">
|
||||
<div className="chamber-tabs">
|
||||
<button className={camaraActiva === 'diputados' ? 'active' : ''} onClick={() => setCamaraActiva('diputados')}>
|
||||
Diputados
|
||||
</button>
|
||||
<button className={camaraActiva === 'senadores' ? 'active' : ''} onClick={() => setCamaraActiva('senadores')}>
|
||||
Senadores
|
||||
</button>
|
||||
<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>
|
||||
<h3>{datosCamaraActual.camaraNombre}</h3>
|
||||
<div className="summary-metric">
|
||||
<div className={styles.summaryMetric}>
|
||||
<span>Total de Bancas</span>
|
||||
<strong>{datosCamaraActual.totalBancas}</strong>
|
||||
</div>
|
||||
<div className="summary-metric">
|
||||
<div className={styles.summaryMetric}>
|
||||
<span>Bancas en Juego</span>
|
||||
<strong>{datosCamaraActual.bancasEnJuego}</strong>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="partido-lista-container">
|
||||
<ul className="partido-lista">
|
||||
<div className={styles.partidoListaContainer}>
|
||||
<ul className={styles.partidoLista}>
|
||||
{partidosOrdenados
|
||||
.filter(p => p.bancasTotales > 0)
|
||||
.map((partido: PartidoComposicionNacional) => (
|
||||
<li key={partido.id}>
|
||||
<span className="partido-color-box" style={{ backgroundColor: partido.color || '#808080' }}></span>
|
||||
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className={styles.partidoColorBox} style={{ 'marginRight': '0.25rem', backgroundColor: partido.color || '#808080' }}></span>
|
||||
<span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
|
||||
<strong
|
||||
className="partido-bancas"
|
||||
className={styles.partidoBancas}
|
||||
title={`${partido.bancasFijos} bancas previas + ${partido.bancasGanadas} ganadas`}
|
||||
>
|
||||
{partido.bancasTotales}
|
||||
@@ -148,14 +149,14 @@ const WidgetContent = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip id="party-tooltip" />
|
||||
<Tooltip id="party-tooltip" className={styles.partyTooltipContainer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CongresoNacionalWidget = ({ eleccionId }: CongresoNacionalWidgetProps) => {
|
||||
return (
|
||||
<Suspense fallback={<div className="congreso-container loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '400px' }}>Cargando composición del congreso...</div>}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,239 +0,0 @@
|
||||
/* src/features/legislativas/nacionales/HomeCarouselWidget.css */
|
||||
|
||||
.home-carousel-widget {
|
||||
--primary-text: #212529;
|
||||
--secondary-text: #6c757d;
|
||||
--border-color: #dee2e6;
|
||||
--background-light: #f8f9fa;
|
||||
--background-white: #ffffff;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
|
||||
--font-family-sans: "Roboto", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.home-carousel-widget {
|
||||
font-family: var(--font-family-sans);
|
||||
background-color: var(--background-white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.widget-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 900;
|
||||
color: var(--primary-text);
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.top-stats-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.top-stats-bar > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: 0 0.5rem;
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-stats-bar > div:last-child { border-right: none; }
|
||||
.top-stats-bar span { font-size: 0.9rem; color: var(--secondary-text); }
|
||||
.top-stats-bar strong { font-size: 0.9rem; font-weight: 600; color: var(--primary-text); }
|
||||
|
||||
.candidate-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--background-white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
height: 100%;
|
||||
border-left: 5px solid;
|
||||
border-left-color: var(--candidate-color, #ccc);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.candidate-photo-wrapper {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--candidate-color, #e9ecef);
|
||||
}
|
||||
|
||||
.candidate-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.candidate-details {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.candidate-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items:flex-start;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.candidate-name, .party-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.candidate-name {
|
||||
font-size: 0.95rem;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text);
|
||||
}
|
||||
.party-name {
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.candidate-results { text-align: right; flex-shrink: 0; }
|
||||
.percentage {
|
||||
display: block;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-text);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.votes {
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.swiper-slide:not(:last-child) .candidate-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 20%;
|
||||
bottom: 20%;
|
||||
width: 1px;
|
||||
background-color: var(--border-color);
|
||||
}
|
||||
|
||||
.swiper-button-prev, .swiper-button-next {
|
||||
width: 30px; height: 30px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity 0.2s;
|
||||
color: var(--secondary-text);
|
||||
}
|
||||
.swiper-button-prev:after, .swiper-button-next:after {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.swiper-button-prev { left: -10px; }
|
||||
.swiper-button-next { right: -10px; }
|
||||
.swiper-button-disabled { opacity: 0; pointer-events: none; }
|
||||
|
||||
.widget-footer {
|
||||
text-align: right;
|
||||
font-size: 0.75rem;
|
||||
color: var(--secondary-text);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.short-text {
|
||||
display: none; /* Oculto por defecto en la vista de escritorio */
|
||||
}
|
||||
|
||||
/* --- INICIO DE LA SECCIÓN DE ESTILOS PARA MÓVIL --- */
|
||||
@media (max-width: 768px) {
|
||||
.home-carousel-widget {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* 1. Centrar el título en móvil */
|
||||
.widget-title {
|
||||
text-align: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 2. Reestructurar la barra de estadísticas a 2x2 y usar textos cortos */
|
||||
.top-stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.top-stats-bar > div {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-right: none; /* Quitar todos los bordes derechos */
|
||||
}
|
||||
|
||||
.top-stats-bar > div:nth-child(odd) {
|
||||
border-right: 1px solid var(--border-color); /* Restablecer borde solo para la columna izquierda */
|
||||
}
|
||||
|
||||
/* Lógica de visibilidad de textos */
|
||||
.long-text {
|
||||
display: none; /* Ocultar el texto largo en móvil */
|
||||
}
|
||||
.short-text {
|
||||
display:inline; /* Mostrar el texto corto en móvil */
|
||||
}
|
||||
|
||||
/* Reducir fuentes para que quepan mejor */
|
||||
.top-stats-bar span { font-size: 0.8rem; text-align: left; }
|
||||
.top-stats-bar strong { font-size: 0.85rem; text-align: right;}
|
||||
|
||||
/* --- Botones del Carrusel (sin cambios) --- */
|
||||
.swiper-button-prev, .swiper-button-next {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
top: 45%;
|
||||
}
|
||||
.swiper-button-prev { left: 2px; }
|
||||
.swiper-button-next { right: 2px; }
|
||||
|
||||
/* --- Ajustes en la tarjeta (sin cambios) --- */
|
||||
.candidate-card { gap: 0.5rem; padding: 0.5rem; }
|
||||
.candidate-photo-wrapper { width: 50px; height: 50px; }
|
||||
.candidate-name { font-size: 0.9rem; }
|
||||
.percentage { font-size: 1.1rem; }
|
||||
.votes { font-size: 0.7rem; }
|
||||
|
||||
/* 3. Centrar el footer en móvil */
|
||||
.widget-footer {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,35 +1,35 @@
|
||||
// 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 './HomeCarouselWidget.css';
|
||||
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');
|
||||
|
||||
// --- Lógica de formateo de fecha ---
|
||||
const formatDateTime = (dateString: string | undefined | null) => {
|
||||
if (!dateString) return '...';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
// Verificar si la fecha es válida
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString; // Si no se puede parsear, devolver el string original
|
||||
return dateString;
|
||||
}
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
@@ -38,96 +38,110 @@ const formatDateTime = (dateString: string | undefined | null) => {
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${day}/${month}/${year}, ${hours}:${minutes} hs.`;
|
||||
} catch (e) {
|
||||
return dateString; // En caso de cualquier error, devolver el string original
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const HomeCarouselWidget = ({ eleccionId, distritoId, categoriaId, titulo }: Props) => {
|
||||
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="home-carousel-widget">
|
||||
<h2 className="widget-title">{titulo}</h2>
|
||||
<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="top-stats-bar">
|
||||
<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="long-text">Mesas escrutadas</span>
|
||||
<span className="short-text">Escrutado</span>
|
||||
<span className={styles.longText}>Mesas escrutadas</span>
|
||||
<span className={styles.shortText}>Escrutado</span>
|
||||
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="long-text">Votos en blanco</span>
|
||||
<span className="short-text">En blanco</span>
|
||||
<span className={styles.longText}>Votos en blanco</span>
|
||||
<span className={styles.shortText}>En blanco</span>
|
||||
<strong>{formatPercent(data.votosEnBlancoPorcentaje)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="long-text">Votos totales</span>
|
||||
<span className="short-text">Votos</span>
|
||||
<span className={styles.longText}>Votos totales</span>
|
||||
<span className={styles.shortText}>Votos</span>
|
||||
<strong>{formatNumber(data.votosTotales)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Swiper
|
||||
modules={[Navigation, A11y]}
|
||||
spaceBetween={16}
|
||||
slidesPerView={1.15}
|
||||
navigation
|
||||
breakpoints={{ 640: { slidesPerView: 2 }, 1024: { slidesPerView: 3 }, 1200: { slidesPerView: 3.5 } }} // Añadir breakpoint
|
||||
>
|
||||
{data.resultados.map(candidato => (
|
||||
<SwiperSlide key={candidato.agrupacionId}>
|
||||
<div className="candidate-card" style={{ '--candidate-color': candidato.color || '#ccc' } as React.CSSProperties}>
|
||||
|
||||
<div className="candidate-photo-wrapper">
|
||||
<ImageWithFallback
|
||||
src={candidato.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={candidato.nombreCandidato ?? ''}
|
||||
className="candidate-photo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="candidate-details">
|
||||
<div className="candidate-info">
|
||||
{candidato.nombreCandidato ? (
|
||||
// CASO 1: Hay un candidato (se muestran dos líneas)
|
||||
<>
|
||||
<span className="candidate-name">
|
||||
{candidato.nombreCandidato}
|
||||
</span>
|
||||
<span className="party-name">
|
||||
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// CASO 2: No hay candidato (se muestra solo una línea)
|
||||
<span className="candidate-name">
|
||||
{candidato.nombreCortoAgrupacion || candidato.nombreAgrupacion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="candidate-results">
|
||||
<span className="percentage">{formatPercent(candidato.porcentaje)}</span>
|
||||
<span className="votes">{formatNumber(candidato.votos)} votos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className="widget-footer">
|
||||
<div className={styles.widgetFooter}>
|
||||
Última actualización: {formatDateTime(data.ultimaActualizacion)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,802 +0,0 @@
|
||||
/* src/features/legislativas/nacionales/PanelNacional.css */
|
||||
.panel-nacional-container {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
/* Necesario para que z-index funcione */
|
||||
z-index: 20;
|
||||
/* Un número alto para ponerlo al frente */
|
||||
background-color: white;
|
||||
/* Asegura que no sea transparente */
|
||||
}
|
||||
|
||||
/* Contenedor para alinear título y selector */
|
||||
.header-top-row {
|
||||
display: flex;
|
||||
justify-content: flex-start; /* Alinea los items al inicio */
|
||||
align-items: center;
|
||||
gap: 2rem; /* Añade un espacio de separación de 2rem entre el selector y el breadcrumb */
|
||||
}
|
||||
|
||||
.categoria-selector {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
/* El contenedor principal del selector (la parte visible antes de hacer clic) */
|
||||
.categoria-selector__control {
|
||||
border-radius: 8px !important;
|
||||
/* Coincide con el radio de los otros elementos */
|
||||
border: 1px solid #e0e0e0 !important;
|
||||
box-shadow: none !important;
|
||||
/* Quitamos la sombra por defecto */
|
||||
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Estilo cuando el selector está enfocado (seleccionado) */
|
||||
.categoria-selector__control--is-focused {
|
||||
border-color: #007bff !important;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
/* El texto del valor seleccionado */
|
||||
.categoria-selector__single-value {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* El menú desplegable que contiene las opciones */
|
||||
.categoria-selector__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;
|
||||
/* Pequeño espacio entre el control y el menú */
|
||||
}
|
||||
|
||||
/* Cada una de las opciones en la lista */
|
||||
.categoria-selector__option {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* Estilo de una opción cuando pasas el mouse por encima (estado 'focused') */
|
||||
.categoria-selector__option--is-focused {
|
||||
background-color: #f0f8ff;
|
||||
/* Un azul muy claro */
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Estilo de la opción que está actualmente seleccionada */
|
||||
.categoria-selector__option--is-selected {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* La pequeña línea vertical que separa el contenido del indicador (la flecha) */
|
||||
.categoria-selector__indicator-separator {
|
||||
display: none;
|
||||
/* La ocultamos para un look más limpio */
|
||||
}
|
||||
|
||||
/* El indicador (la flecha hacia abajo) */
|
||||
.categoria-selector__indicator {
|
||||
color: #a0a0a0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.categoria-selector__indicator:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* --- ESTILOS MODERNOS PARA BREADCRUMBS --- */
|
||||
|
||||
.breadcrumbs-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
/* Espacio entre elementos */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-item,
|
||||
.breadcrumb-item-actual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
/* Bordes redondeados para efecto píldora */
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
background-color: #f0f0f0;
|
||||
border: 1px solid #e0e0e0;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
background-color: #e0e0e0;
|
||||
border-color: #d1d1d1;
|
||||
}
|
||||
|
||||
.breadcrumb-item-actual {
|
||||
background-color: transparent;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
/* Más peso para el nivel actual */
|
||||
}
|
||||
|
||||
.breadcrumb-icon {
|
||||
margin-right: 0.4rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: #a0a0a0;
|
||||
/* Color sutil para el separador */
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.panel-main-content {
|
||||
display: flex;
|
||||
height: 75vh;
|
||||
min-height: 500px;
|
||||
transition: all 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Columna del mapa */
|
||||
.mapa-column {
|
||||
flex: 2;
|
||||
/* Por defecto, ocupa 2/3 del espacio */
|
||||
position: relative;
|
||||
transition: flex 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Columna de resultados */
|
||||
.resultados-column {
|
||||
flex: 1;
|
||||
/* Por defecto, ocupa 1/3 */
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.5s ease-in-out;
|
||||
min-width: 320px;
|
||||
/* Un ancho mínimo para que no se comprima demasiado */
|
||||
}
|
||||
|
||||
/* --- NUEVO LAYOUT PARA TARJETAS DE PARTIDO --- */
|
||||
.partido-fila {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-left: 5px solid;
|
||||
border-radius: 12px;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.partido-logo {
|
||||
flex-shrink: 0;
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
}
|
||||
|
||||
.partido-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 10%;
|
||||
}
|
||||
|
||||
.partido-main-content {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
/* CAMBIO: De flex a grid */
|
||||
grid-template-columns: 1fr auto;
|
||||
/* Columna 1 (nombre) flexible, Columna 2 (stats) se ajusta al contenido */
|
||||
grid-template-rows: auto auto;
|
||||
/* Dos filas: una para la info, otra para la barra */
|
||||
align-items: center;
|
||||
/* Alinea verticalmente el contenido de ambas filas */
|
||||
gap: 0.25rem 1rem;
|
||||
/* Espacio entre filas y columnas (0.25rem vertical, 1rem horizontal) */
|
||||
}
|
||||
|
||||
.partido-top-row {
|
||||
/* Hacemos que este contenedor sea "invisible" para el grid,
|
||||
promoviendo a sus hijos (info y stats) a la cuadrícula principal. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.partido-info-wrapper {
|
||||
/* Ocupa el espacio disponible a la izquierda */
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
color: #212529;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.candidato-nombre {
|
||||
font-size: 0.8rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.partido-stats {
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
padding-left: 1rem;
|
||||
/* Ya no necesita ser un contenedor flex, el grid lo posiciona */
|
||||
}
|
||||
|
||||
.partido-porcentaje {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.partido-votos {
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.partido-barra-background {
|
||||
height: 20px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
grid-column: 1 / 3;
|
||||
/* Le indicamos que ocupe ambas columnas (de la línea 1 a la 3) */
|
||||
}
|
||||
|
||||
.partido-barra-foreground {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* ------------------------------------------- */
|
||||
|
||||
.panel-estado-recuento {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.estado-item {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estado-item span {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
/* --- MAPA Y ELEMENTOS ASOCIADOS --- */
|
||||
.mapa-componente-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mapa-render-area {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mapa-volver-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.rsm-zoomable-group {
|
||||
transition: transform 0.75s ease-in-out;
|
||||
}
|
||||
|
||||
/* AÑADIDO: Desactivar la transición durante el arrastre */
|
||||
.rsm-zoomable-group.panning {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.panel-main-content.panel-collapsed .mapa-column {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.panel-main-content.panel-collapsed .resultados-column {
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-toggle-btn {
|
||||
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;
|
||||
}
|
||||
|
||||
.panel-toggle-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.rsm-geography {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.25px;
|
||||
outline: none;
|
||||
transition: filter 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.rsm-geography:not(.selected):hover {
|
||||
filter: brightness(1.25);
|
||||
/* Mantenemos el brillo */
|
||||
stroke: #ffffff;
|
||||
/* Color del borde a blanco */
|
||||
stroke-width: 0.25px;
|
||||
paint-order: stroke;
|
||||
/* Asegura que el borde se dibuje encima del relleno */
|
||||
}
|
||||
|
||||
.rsm-geography.selected {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.25px;
|
||||
filter: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rsm-geography-faded,
|
||||
.rsm-geography-faded-municipality {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.caba-comuna-geography {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.05px;
|
||||
}
|
||||
|
||||
.caba-comuna-geography:not(.selected):hover {
|
||||
stroke: #000000;
|
||||
stroke-width: 0.055px;
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
|
||||
.transition-spinner {
|
||||
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;
|
||||
}
|
||||
|
||||
.transition-spinner::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);
|
||||
}
|
||||
}
|
||||
|
||||
.caba-magnifier-container {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.caba-lupa-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.caba-lupa-interactive-area {
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(0px 2px 4px rgba(0, 0, 0, 0.25));
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.caba-lupa-interactive-area:hover {
|
||||
filter: brightness(1.15);
|
||||
stroke: #ffffff;
|
||||
stroke-width: 0.25px;
|
||||
}
|
||||
|
||||
.skeleton-fila 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;
|
||||
}
|
||||
|
||||
.skeleton-logo {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.skeleton-bar {
|
||||
height: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* --- NUEVOS ESTILOS PARA EL TOGGLE MÓVIL --- */
|
||||
.mobile-view-toggle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
/* <-- CAMBIO: De 'fixed' a 'absolute' */
|
||||
bottom: 10px;
|
||||
/* <-- AJUSTE: Menos espacio desde abajo */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 100;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 30px;
|
||||
padding: 5px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
gap: 5px;
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.mobile-view-toggle .toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
.mobile-view-toggle .toggle-btn.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA LOS BOTONES DE ZOOM DEL MAPA --- */
|
||||
.zoom-controls-container {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 10px;
|
||||
z-index: 30;
|
||||
/* Debe ser MAYOR que el z-index del header (20) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.zoom-icon-wrapper {
|
||||
/* Contenedor del icono */
|
||||
display: flex;
|
||||
/* Necesario para que el SVG interno se alinee */
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.zoom-icon-wrapper svg {
|
||||
/* Apunta directamente al SVG del icono */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.zoom-btn.disabled {
|
||||
opacity: 0.5;
|
||||
/* Lo hace semitransparente */
|
||||
cursor: not-allowed;
|
||||
/* Muestra el cursor de "no permitido" */
|
||||
}
|
||||
|
||||
.zoom-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
/* --- ESTILOS DE CURSOR PARA EL ARRASTRE DEL MAPA --- */
|
||||
.map-locked .rsm-geography {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-pannable .rsm-geography {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
/* El cursor 'grabbing' se aplica automáticamente por el navegador durante el arrastre */
|
||||
|
||||
|
||||
/* --- MEDIA QUERY PARA RESPONSIVE (ENFOQUE FINAL CON CAPAS) --- */
|
||||
@media (max-width: 800px) {
|
||||
|
||||
/* --- CONFIGURACIÓN GENERAL --- */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Controles de vista y header (sin cambios) */
|
||||
.mobile-view-toggle {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.panel-toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-top-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.categoria-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- NUEVO LAYOUT DE CAPAS SUPERPUESTAS --- */
|
||||
|
||||
/* 1. El contenedor principal ahora es un ancla de posicionamiento */
|
||||
.panel-main-content {
|
||||
position: relative;
|
||||
/* Clave para que los hijos se posicionen dentro de él */
|
||||
height: calc(100vh - 200px);
|
||||
/* Le damos una altura fija y predecible */
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
/* 2. Ambas columnas son capas que ocupan el 100% del espacio del padre */
|
||||
.mapa-column,
|
||||
.resultados-column {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
/* Le damos un estilo específico a la columna del mapa para subirla */
|
||||
.mapa-column {
|
||||
top: -50px;
|
||||
left: -10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
|
||||
/* Hacemos que la columna de resultados pueda tener su propio scroll... */
|
||||
.resultados-column {
|
||||
top: 0;
|
||||
/* Aseguramos que los resultados se queden en su sitio */
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* 3. Lógica de visibilidad: controlamos qué capa está "arriba" */
|
||||
.panel-main-content.mobile-view-mapa .resultados-column {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
/* Esta es la propiedad clave que ya tenías, pero es importante verificarla */
|
||||
pointer-events: none;
|
||||
/* Asegura que la capa oculta no bloquee el mapa */
|
||||
}
|
||||
|
||||
.panel-main-content.mobile-view-resultados .mapa-column {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Hacemos que la columna de resultados pueda tener su propio scroll si el contenido es largo */
|
||||
.resultados-column {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 4. Estilos de los resultados (ya estaban bien, se mantienen) */
|
||||
.partido-fila {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
border-left: 5px solid;
|
||||
/* Grosor del borde */
|
||||
border-radius: 12px;
|
||||
/* Redondeamos las esquinas */
|
||||
padding-left: 1rem;
|
||||
/* Espacio a la izquierda */
|
||||
}
|
||||
|
||||
.partido-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partido-main-content {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partido-top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.partido-info-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partido-nombre {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.partido-stats {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- AJUSTE DE TAMAÑO DEL CONTENEDOR INTERNO DEL MAPA --- */
|
||||
.mapa-column .mapa-componente-container,
|
||||
.mapa-column .mapa-render-area {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Margen de seguridad para el último elemento de la lista de resultados */
|
||||
.panel-partidos-container .partido-fila:last-child {
|
||||
margin-bottom: 90px;
|
||||
}
|
||||
|
||||
.zoom-controls-container {
|
||||
top: 55px;
|
||||
}
|
||||
|
||||
.mapa-volver-btn {
|
||||
top: 55px;
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
/* --- MEDIA QUERY ADICIONAL PARA MÓVIL EN HORIZONTAL --- */
|
||||
/* Se activa cuando la pantalla es ancha pero no muy alta, como un teléfono en landscape */
|
||||
@media (max-width: 900px) and (orientation: landscape) {
|
||||
|
||||
/* Layout flexible de dos columnas */
|
||||
.panel-main-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: static;
|
||||
height: 85vh;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.mapa-column,
|
||||
.resultados-column {
|
||||
position: static;
|
||||
/* Desactivamos el posicionamiento absoluto */
|
||||
height: auto;
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
flex: 3;
|
||||
overflow-y: auto;
|
||||
/* Permitimos que la columna de resultados tenga su propio scroll */
|
||||
}
|
||||
|
||||
.resultados-column {
|
||||
flex: 2;
|
||||
min-width: 300px;
|
||||
/* Un mínimo para que no se comprima */
|
||||
}
|
||||
|
||||
/* 3. Ocultamos los botones de cambio de vista móvil, ya que ambas se ven */
|
||||
.mobile-view-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 4. Mostramos de nuevo el botón lateral para colapsar el panel de resultados */
|
||||
.panel-toggle-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -1,17 +1,127 @@
|
||||
// src/features/legislativas/nacionales/PanelNacionalWidget.tsx
|
||||
import { useMemo, useState, Suspense } from 'react';
|
||||
|
||||
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 './PanelNacional.css';
|
||||
import { MunicipioSearch } from './components/MunicipioSearch';
|
||||
import styles from './PanelNacional.module.css';
|
||||
import Select from 'react-select';
|
||||
import type { PanelElectoralDto } from '../../../types/types';
|
||||
import { FiMap, FiList } from 'react-icons/fi';
|
||||
import type { PanelElectoralDto, ResultadoTicker } from '../../../types/types';
|
||||
import { FiMap, FiList, FiChevronDown, FiChevronUp } from 'react-icons/fi';
|
||||
import { useMediaQuery } from './hooks/useMediaQuery';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
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;
|
||||
}
|
||||
@@ -25,27 +135,48 @@ type AmbitoState = {
|
||||
};
|
||||
|
||||
const CATEGORIAS_NACIONALES = [
|
||||
{ value: 2, label: 'Diputados Nacionales' },
|
||||
{ value: 1, label: 'Senadores 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],
|
||||
queryFn: () => getPanelElectoral(eleccionId, ambitoActual.id, categoriaId),
|
||||
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>(2);
|
||||
const [categoriaId, setCategoriaId] = useState<number>(3);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [mobileView, setMobileView] = useState<'mapa' | 'resultados'>('mapa');
|
||||
// --- DETECCIÓN DE VISTA MÓVIL ---
|
||||
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,
|
||||
@@ -78,19 +209,30 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
|
||||
[categoriaId]
|
||||
);
|
||||
|
||||
const mainContentClasses = [
|
||||
styles.panelMainContent,
|
||||
!isPanelOpen ? styles.panelCollapsed : '',
|
||||
isMobile ? styles[`mobile-view-${mobileView}`] : ''
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<div className="panel-nacional-container">
|
||||
<Toaster containerClassName="widget-toaster-container" />
|
||||
<header className="panel-header">
|
||||
<div className="header-top-row">
|
||||
<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)}
|
||||
className="categoria-selector"
|
||||
classNamePrefix="categoria-selector"
|
||||
classNamePrefix="categoriaSelector"
|
||||
className={styles.categoriaSelectorContainer}
|
||||
isSearchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.headerBottomRow}>
|
||||
<Breadcrumbs
|
||||
nivel={ambitoActual.nivel}
|
||||
nombreAmbito={ambitoActual.nombre}
|
||||
@@ -98,55 +240,44 @@ export const PanelNacionalWidget = ({ eleccionId }: PanelNacionalWidgetProps) =>
|
||||
onReset={handleResetToPais}
|
||||
onVolverProvincia={handleVolverAProvincia}
|
||||
/>
|
||||
{ambitoActual.nivel === 'provincia' && ambitoActual.provinciaDistritoId && (
|
||||
<MunicipioSearch
|
||||
distritoId={ambitoActual.provinciaDistritoId}
|
||||
onMunicipioSelect={(municipioId, municipioNombre) =>
|
||||
handleAmbitoSelect(municipioId, 'municipio', municipioNombre)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className={`panel-main-content ${!isPanelOpen ? 'panel-collapsed' : ''} ${isMobile ? `mobile-view-${mobileView}` : ''}`}>
|
||||
<div className="mapa-column">
|
||||
<button className="panel-toggle-btn" onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}>
|
||||
{isPanelOpen ? '›' : '‹'}
|
||||
</button>
|
||||
<Suspense fallback={<div className="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="resultados-column">
|
||||
<Suspense fallback={<div className="spinner" />}>
|
||||
<PanelContenido
|
||||
eleccionId={eleccionId}
|
||||
ambitoActual={ambitoActual}
|
||||
categoriaId={categoriaId}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
<main className={mainContentClasses}>
|
||||
<div className={styles.mapaColumn}>
|
||||
<button className={styles.panelToggleBtn} onClick={() => setIsPanelOpen(!isPanelOpen)} title={isPanelOpen ? "Ocultar panel" : "Mostrar panel"}> {isPanelOpen ? '›' : '‹'} </button>
|
||||
|
||||
{/* --- NUEVO CONTROLADOR DE VISTA PARA MÓVIL --- */}
|
||||
<div className="mobile-view-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${mobileView === 'mapa' ? 'active' : ''}`}
|
||||
onClick={() => setMobileView('mapa')}
|
||||
>
|
||||
<FiMap />
|
||||
<span>Mapa</span>
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${mobileView === 'resultados' ? 'active' : ''}`}
|
||||
onClick={() => setMobileView('resultados')}
|
||||
>
|
||||
<FiList />
|
||||
<span>Resultados</span>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,26 +1,28 @@
|
||||
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.css */
|
||||
/* src/features/legislativas/nacionales/ResultadosNacionalesCardsWidget.module.css */
|
||||
|
||||
/* --- Variables de Diseño --- */
|
||||
:root {
|
||||
/* --- 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: #f8f9fa;
|
||||
--card-header-bg-color: #e6f1fd;
|
||||
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #6c757d;
|
||||
--font-family: "Roboto", system-ui, sans-serif;
|
||||
--primary-accent-color: #007bff;
|
||||
}
|
||||
|
||||
/* --- Contenedor Principal del Widget --- */
|
||||
.cards-widget-container {
|
||||
font-family: var(--font-family);
|
||||
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.cards-widget-container h2 {
|
||||
.cardsWidgetContainer h2 {
|
||||
font-size: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1.5rem;
|
||||
@@ -29,27 +31,26 @@
|
||||
}
|
||||
|
||||
/* --- Grilla de Tarjetas --- */
|
||||
.cards-grid {
|
||||
.cardsGrid {
|
||||
display: grid;
|
||||
/* Crea columnas flexibles que se ajustan al espacio disponible */
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* --- Tarjeta Individual --- */
|
||||
.provincia-card {
|
||||
.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; /* Asegura que los bordes redondeados se apliquen al contenido */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Cabecera de la Tarjeta --- */
|
||||
.card-header {
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -58,83 +59,86 @@
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
}
|
||||
|
||||
.header-info h3 {
|
||||
.headerInfo h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-info span {
|
||||
.headerInfo span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-map {
|
||||
.headerMap {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background-color: #e9ecef;
|
||||
background-color: #f7fbff;
|
||||
padding: 0.25rem;
|
||||
box-sizing: border-box; /* Para que el padding no aumente el tamaño total */
|
||||
}
|
||||
|
||||
/* Contenedor del SVG para asegurar que se ajuste al espacio */
|
||||
.map-svg-container, .map-placeholder {
|
||||
.mapSvgContainer, .mapPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Estilo para el SVG renderizado */
|
||||
.map-svg-container svg {
|
||||
.mapSvgContainer svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain; /* Asegura que el mapa no se deforme */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Placeholder para cuando el mapa no carga */
|
||||
.map-placeholder.error {
|
||||
background-color: #f8d7da; /* Un color de fondo rojizo para indicar un error */
|
||||
.mapPlaceholder.error {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
/* --- Cuerpo de la Tarjeta --- */
|
||||
.card-body {
|
||||
.cardBody {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.candidato-row {
|
||||
.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; /* Grosor del borde */
|
||||
border-radius: 12px; /* Redondeamos las esquinas para un look más suave */
|
||||
padding-left: 1rem; /* Añadimos un poco de espacio a la izquierda */
|
||||
border-left: 5px solid;
|
||||
border-radius: 12px;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.candidato-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.candidato-foto {
|
||||
.candidatoFotoWrapper {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 5%;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.candidato-data {
|
||||
.candidatoFoto {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.candidatoData {
|
||||
flex-grow: 1;
|
||||
min-width: 0; /* Permite que el texto se trunque si es necesario */
|
||||
min-width: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.candidato-nombre {
|
||||
.candidatoNombre {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
@@ -142,29 +146,28 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.candidato-partido {
|
||||
.candidatoPartido {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
.progressBarContainer {
|
||||
height: 16px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
.progressBar {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.candidato-stats {
|
||||
.candidatoStats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
@@ -173,18 +176,18 @@
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.stats-percent {
|
||||
.statsPercent {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats-votos {
|
||||
.statsVotos {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stats-bancas {
|
||||
.statsBancas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -200,17 +203,15 @@
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.stats-bancas span {
|
||||
.statsBancas span {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
|
||||
/* --- Pie de la Tarjeta --- */
|
||||
.card-footer {
|
||||
.cardFooter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
background-color: var(--card-header-bg-color);
|
||||
@@ -219,21 +220,21 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-footer div {
|
||||
.cardFooter div {
|
||||
border-right: 1px solid var(--card-border-color);
|
||||
}
|
||||
|
||||
.card-footer div:last-child {
|
||||
.cardFooter div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.card-footer span {
|
||||
.cardFooter span {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.card-footer strong {
|
||||
.cardFooter strong {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
@@ -241,43 +242,39 @@
|
||||
|
||||
/* --- Media Query para Móvil --- */
|
||||
@media (max-width: 480px) {
|
||||
.cards-grid {
|
||||
/* En pantallas muy pequeñas, forzamos una sola columna */
|
||||
.cardsGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
.cardHeader {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.header-info h3 {
|
||||
.headerInfo h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- ESTILOS PARA EL NOMBRE DEL PARTIDO CUANDO ES EL TÍTULO PRINCIPAL --- */
|
||||
.candidato-partido.main-title {
|
||||
font-size: 0.95rem; /* Hacemos la fuente más grande */
|
||||
font-weight: 700; /* La ponemos en negrita, como el nombre del candidato */
|
||||
color: var(--text-primary); /* Usamos el color de texto principal */
|
||||
text-transform: none; /* Quitamos el 'uppercase' para que se lea mejor */
|
||||
.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 --- */
|
||||
|
||||
.categoria-bloque {
|
||||
.categoriaBloque {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Añadimos un separador si hay más de una categoría en la misma tarjeta */
|
||||
.categoria-bloque + .categoria-bloque {
|
||||
.categoriaBloque + .categoriaBloque {
|
||||
border-top: 1px dashed var(--card-border-color);
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.categoria-titulo {
|
||||
.categoriaTitulo {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
@@ -285,19 +282,18 @@
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
/* Ajuste para el footer, que ahora está dentro de cada categoría */
|
||||
.categoria-bloque .card-footer {
|
||||
.categoriaBloque .cardFooter {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
background-color: transparent; /* Quitamos el fondo gris */
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--card-border-color);
|
||||
padding: 0.75rem 0;
|
||||
margin-top: 0.75rem; /* Espacio antes del footer */
|
||||
margin-top: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.categoria-bloque .card-footer div {
|
||||
.categoriaBloque .cardFooter div {
|
||||
border-right: 1px solid var(--card-border-color);
|
||||
}
|
||||
.categoria-bloque .card-footer div:last-child {
|
||||
.categoriaBloque .cardFooter div:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
@@ -2,24 +2,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getResumenPorProvincia } from '../../../apiService';
|
||||
import { ProvinciaCard } from './components/ProvinciaCard';
|
||||
import './ResultadosNacionalesCardsWidget.css';
|
||||
import styles from './ResultadosNacionalesCardsWidget.module.css';
|
||||
|
||||
// --- 1. AÑADIR LA PROP A LA INTERFAZ ---
|
||||
interface Props {
|
||||
eleccionId: number;
|
||||
focoDistritoId?: string;
|
||||
focoCategoriaId?: number;
|
||||
cantidadResultados?: number;
|
||||
mostrarBancas?: boolean; // Booleano opcional
|
||||
mostrarBancas?: boolean;
|
||||
}
|
||||
|
||||
// --- 2. RECIBIR LA PROP Y ESTABLECER UN VALOR POR DEFECTO ---
|
||||
export const ResultadosNacionalesCardsWidget = ({
|
||||
eleccionId,
|
||||
focoDistritoId,
|
||||
focoCategoriaId,
|
||||
cantidadResultados,
|
||||
mostrarBancas = false // Por defecto, no se muestran las bancas
|
||||
mostrarBancas = false
|
||||
}: Props) => {
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -30,6 +28,7 @@ export const ResultadosNacionalesCardsWidget = ({
|
||||
focoCategoriaId,
|
||||
cantidadResultados
|
||||
}),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Cargando resultados por provincia...</div>;
|
||||
@@ -37,8 +36,8 @@ export const ResultadosNacionalesCardsWidget = ({
|
||||
if (!data || data.length === 0) return <div>No hay resultados para mostrar con los filtros seleccionados.</div>
|
||||
|
||||
return (
|
||||
<section className="cards-widget-container">
|
||||
<div className="cards-grid">
|
||||
<section className={styles.cardsWidgetContainer}>
|
||||
<div className={styles.cardsGrid}>
|
||||
{data?.map(provinciaData => (
|
||||
<ProvinciaCard
|
||||
key={provinciaData.provinciaId}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
// src/features/legislativas/nacionales/components/Breadcrumbs.tsx
|
||||
import { FiHome, FiChevronRight } from 'react-icons/fi';
|
||||
// 1. Importamos el archivo de estilos como un módulo CSS
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
nivel: 'pais' | 'provincia' | 'municipio';
|
||||
@@ -10,36 +12,37 @@ interface BreadcrumbsProps {
|
||||
}
|
||||
|
||||
export const Breadcrumbs = ({ nivel, nombreAmbito, nombreProvincia, onReset, onVolverProvincia }: BreadcrumbsProps) => {
|
||||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||||
return (
|
||||
<nav className="breadcrumbs-container">
|
||||
<nav className={styles.breadcrumbsContainer}>
|
||||
{nivel !== 'pais' ? (
|
||||
<>
|
||||
<button onClick={onReset} className="breadcrumb-item">
|
||||
<FiHome className="breadcrumb-icon" />
|
||||
<button onClick={onReset} className={styles.breadcrumbItem}>
|
||||
<FiHome className={styles.breadcrumbIcon} />
|
||||
<span>Argentina</span>
|
||||
</button>
|
||||
<FiChevronRight className="breadcrumb-separator" />
|
||||
<FiChevronRight className={styles.breadcrumbSeparator} />
|
||||
</>
|
||||
) : (
|
||||
<div className="breadcrumb-item-actual">
|
||||
<FiHome className="breadcrumb-icon" />
|
||||
<div className={styles.breadcrumbItemActual}>
|
||||
<FiHome className={styles.breadcrumbIcon} />
|
||||
<span>{nombreAmbito}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nivel === 'provincia' && (
|
||||
<div className="breadcrumb-item-actual">
|
||||
<div className={styles.breadcrumbItemActual}>
|
||||
<span>{nombreAmbito}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nivel === 'municipio' && nombreProvincia && (
|
||||
<>
|
||||
<button onClick={onVolverProvincia} className="breadcrumb-item">
|
||||
<button onClick={onVolverProvincia} className={styles.breadcrumbItem}>
|
||||
<span>{nombreProvincia}</span>
|
||||
</button>
|
||||
<FiChevronRight className="breadcrumb-separator" />
|
||||
<div className="breadcrumb-item-actual">
|
||||
<FiChevronRight className={styles.breadcrumbSeparator} />
|
||||
<div className={styles.breadcrumbItemActual}>
|
||||
<span>{nombreAmbito}</span>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// src/features/legislativas/nacionales/components/CabaLupa.tsx
|
||||
|
||||
// 1. Importamos el archivo de estilos como un módulo CSS
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
interface CabaLupaProps {
|
||||
fillColor: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const CabaLupa = ({ fillColor, onClick }: CabaLupaProps) => {
|
||||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||||
return (
|
||||
<svg viewBox="0 0 542 256" className="caba-lupa-svg">
|
||||
<svg viewBox="0 0 542 256" className={styles.cabaLupaSvg}>
|
||||
<g transform="translate(10, 10)">
|
||||
<g
|
||||
onClick={onClick}
|
||||
className="caba-lupa-interactive-area"
|
||||
className={styles.cabaLupaInteractiveArea}
|
||||
>
|
||||
<g transform="rotate(25.8155, 280.549, 151.582)">
|
||||
<ellipse ry="116.11771" rx="123.49187" cy="69.21453" cx="372.69183" stroke="#999" strokeWidth="2" fill="#fff" />
|
||||
|
||||
@@ -12,6 +12,9 @@ import { MapaProvincial } from './MapaProvincial';
|
||||
import { CabaLupa } from './CabaLupa';
|
||||
import { BiZoomIn, BiZoomOut } from "react-icons/bi";
|
||||
import toast from 'react-hot-toast';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
// 1. Importamos el archivo de estilos como un módulo CSS
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
const DEFAULT_MAP_COLOR = '#E0E0E0';
|
||||
const FADED_BACKGROUND_COLOR = '#F0F0F0';
|
||||
@@ -19,15 +22,21 @@ const normalizarTexto = (texto: string = '') => texto.trim().toUpperCase().norma
|
||||
|
||||
type PointTuple = [number, number];
|
||||
|
||||
const PROVINCE_VIEW_CONFIG: Record<string, { center: PointTuple; zoom: number }> = {
|
||||
"BUENOS AIRES": { center: [-60.5, -37.3], zoom: 5 },
|
||||
"SANTA CRUZ": { center: [-69.5, -48.8], zoom: 5 },
|
||||
"CIUDAD AUTONOMA DE BUENOS AIRES": { center: [-58.45, -34.6], zoom: 85 },
|
||||
"CHUBUT": { center: [-68.5, -44.5], zoom: 5.5 },
|
||||
"SANTA FE": { center: [-61, -31.2], zoom: 6 },
|
||||
"CORRIENTES": { center: [-58, -29], zoom: 7 },
|
||||
"RIO NEGRO": { center: [-67.5, -40], zoom: 5.5 },
|
||||
"TIERRA DEL FUEGO": { center: [-66.5, -54.2], zoom: 7 },
|
||||
interface ViewConfig {
|
||||
center: PointTuple;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
const PROVINCE_VIEW_CONFIG: Record<string, { desktop: ViewConfig; mobile?: ViewConfig }> = {
|
||||
"BUENOS AIRES": { desktop: { center: [-60.5, -37.3], zoom: 5 }, mobile: { center: [-60, -38], zoom: 5.5 } },
|
||||
"SANTA CRUZ": { desktop: { center: [-69.5, -49.3], zoom: 5 }, mobile: { center: [-69.5, -50], zoom: 4 } },
|
||||
"CABA": { desktop: { center: [-58.44, -34.65], zoom: 150 } },
|
||||
"CHUBUT": { desktop: { center: [-68.5, -44.5], zoom: 5.5 }, mobile: { center: [-68, -44.5], zoom: 4.5 } },
|
||||
"SANTA FE": { desktop: { center: [-61, -31.2], zoom: 6 }, mobile: { center: [-61, -31.5], zoom: 7.5 } },
|
||||
"CORRIENTES": { desktop: { center: [-58, -29], zoom: 7 }, mobile: { center: [-57.5, -28.8], zoom: 9 } },
|
||||
"RIO NEGRO": { desktop: { center: [-67.5, -40], zoom: 5.5 }, mobile: { center: [-67.5, -40], zoom: 4.3 } },
|
||||
"SALTA": { desktop: { center: [-64.5, -24], zoom: 7 }, mobile: { center: [-65.5, -24.5], zoom: 6 } },
|
||||
"TIERRA DEL FUEGO": { desktop: { center: [-66.5, -54.2], zoom: 7 }, mobile: { center: [-66, -54], zoom: 7.5 } },
|
||||
};
|
||||
|
||||
const LUPA_SIZE_RATIO = 0.2;
|
||||
@@ -46,17 +55,19 @@ interface MapaNacionalProps {
|
||||
isMobileView: boolean;
|
||||
}
|
||||
|
||||
// --- CONFIGURACIONES DEL MAPA ---
|
||||
const desktopProjectionConfig = { scale: 700, center: [-65, -40] as [number, number] };
|
||||
const mobileProjectionConfig = { scale: 1100, center: [-64, -41] as [number, number] };
|
||||
const desktopProjectionConfig = { scale: 1000, center: [-65, -40] as [number, number] };
|
||||
const mobileProjectionConfig = { scale: 1000, center: [-64, -43] as [number, number] };
|
||||
const mobileSmallProjectionConfig = { scale: 750, center: [-64, -45] as [number, number] };
|
||||
|
||||
export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nombreProvinciaActiva, provinciaDistritoId, onAmbitoSelect, onVolver, isMobileView }: MapaNacionalProps) => {
|
||||
const isMobileSmall = useMediaQuery('(max-width: 380px)');
|
||||
|
||||
const [position, setPosition] = useState({
|
||||
zoom: isMobileView ? 1.5 : 1.05, // 1.5 para móvil, 1.05 para desktop
|
||||
center: [-65, -40] as PointTuple
|
||||
zoom: isMobileView ? 1.5 : 1.05,
|
||||
center: isMobileView ? mobileProjectionConfig.center : desktopProjectionConfig.center as PointTuple
|
||||
});
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const initialProvincePositionRef = useRef<{ zoom: number, center: PointTuple } | null>(null);
|
||||
const initialProvincePositionRef = useRef<ViewConfig | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lupaRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -73,6 +84,7 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
const response = await axios.get(url);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: geoDataNacional } = useSuspenseQuery<any>({
|
||||
@@ -82,32 +94,37 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
|
||||
useEffect(() => {
|
||||
if (nivel === 'pais') {
|
||||
const currentMobileConfig = isMobileSmall ? mobileSmallProjectionConfig : mobileProjectionConfig;
|
||||
const currentMobileZoom = isMobileSmall ? 1.4 : 1.5;
|
||||
|
||||
setPosition({
|
||||
zoom: isMobileView ? 1.4 : 1.05,
|
||||
center: [-65, -40]
|
||||
zoom: isMobileView ? currentMobileZoom : 1.05,
|
||||
center: isMobileView ? currentMobileConfig.center : desktopProjectionConfig.center
|
||||
});
|
||||
initialProvincePositionRef.current = null;
|
||||
} else if (nivel === 'provincia') {
|
||||
const nombreNormalizado = normalizarTexto(nombreAmbito);
|
||||
const manualConfig = PROVINCE_VIEW_CONFIG[nombreNormalizado];
|
||||
|
||||
let provinceConfig = { zoom: 7, center: [-65, -40] as PointTuple };
|
||||
let provinceConfig: ViewConfig | undefined;
|
||||
|
||||
if (manualConfig) {
|
||||
provinceConfig = manualConfig;
|
||||
provinceConfig = (isMobileView && manualConfig.mobile) ? manualConfig.mobile : manualConfig.desktop;
|
||||
} else {
|
||||
const provinciaGeo = geoDataNacional.objects.provincias.geometries.find((g: any) => normalizarTexto(g.properties.nombre) === nombreNormalizado);
|
||||
if (provinciaGeo) {
|
||||
const provinciaFeature = feature(geoDataNacional, provinciaGeo);
|
||||
const centroid = geoCentroid(provinciaFeature);
|
||||
provinceConfig = { zoom: 7, center: centroid as PointTuple };
|
||||
provinceConfig = { zoom: isMobileView ? 8 : 7, center: centroid as PointTuple };
|
||||
}
|
||||
}
|
||||
|
||||
setPosition(provinceConfig);
|
||||
initialProvincePositionRef.current = provinceConfig;
|
||||
if (provinceConfig) {
|
||||
setPosition(provinceConfig);
|
||||
initialProvincePositionRef.current = provinceConfig;
|
||||
}
|
||||
}
|
||||
}, [nivel, nombreAmbito, geoDataNacional, isMobileView]);
|
||||
}, [nivel, nombreAmbito, geoDataNacional, isMobileView, isMobileSmall]);
|
||||
|
||||
const resultadosNacionalesPorNombre = new Map<string, ResultadoMapaDto>(mapaDataNacional.map(d => [normalizarTexto(d.ambitoNombre), d]));
|
||||
const nombreMunicipioSeleccionado = nivel === 'municipio' ? nombreAmbito : null;
|
||||
@@ -173,18 +190,13 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
position.zoom > initialProvincePositionRef.current.zoom &&
|
||||
!nombreMunicipioSeleccionado;
|
||||
|
||||
// --- INICIO DE LA CORRECCIÓN ---
|
||||
|
||||
const handleZoomIn = () => {
|
||||
// Solo mostramos la notificación si el paneo NO está ya habilitado
|
||||
if (!panEnabled && initialProvincePositionRef.current) {
|
||||
// Calculamos cuál será el nuevo nivel de zoom
|
||||
const newZoom = position.zoom * 1.8;
|
||||
// Si el nuevo zoom supera el umbral inicial, activamos la notificación
|
||||
if (newZoom > initialProvincePositionRef.current.zoom) {
|
||||
toast.success('Desplazamiento Habilitado', {
|
||||
icon: '🖐️',
|
||||
style: { background: '#32e5f1ff', color: 'white' },
|
||||
style: { background: '#32e5f1ff', color: 'white', zIndex: 9999 },
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
@@ -193,10 +205,8 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
// Solo mostramos la notificación si el paneo SÍ está habilitado actualmente
|
||||
if (panEnabled && initialProvincePositionRef.current) {
|
||||
const newZoom = position.zoom / 1.8;
|
||||
// Si el nuevo zoom es igual o menor al umbral, desactivamos
|
||||
if (newZoom <= initialProvincePositionRef.current.zoom) {
|
||||
toast.error('Desplazamiento Deshabilitado', {
|
||||
icon: '🔒',
|
||||
@@ -205,7 +215,6 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
});
|
||||
}
|
||||
}
|
||||
// La lógica para actualizar la posición no cambia
|
||||
setPosition(prev => {
|
||||
const newZoom = Math.max(prev.zoom / 1.8, 1);
|
||||
const initialPos = initialProvincePositionRef.current;
|
||||
@@ -229,32 +238,33 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
(nivel === 'provincia' && initialProvincePositionRef.current && position.zoom <= initialProvincePositionRef.current.zoom) ||
|
||||
(nivel === 'pais' && position.zoom <= (isMobileView ? 1.4 : 1.05));
|
||||
|
||||
const mapContainerClasses = panEnabled ? 'mapa-componente-container map-pannable' : 'mapa-componente-container map-locked';
|
||||
// 2. Todas las props 'className' ahora usan el objeto 'styles'
|
||||
const mapContainerClasses = `${styles.mapaComponenteContainer} ${panEnabled ? styles.mapPannable : styles.mapLocked}`;
|
||||
|
||||
return (
|
||||
<div className={mapContainerClasses} ref={containerRef}>
|
||||
{showZoomControls && (
|
||||
<div className="zoom-controls-container">
|
||||
<button onClick={handleZoomIn} className="zoom-btn" title="Acercar">
|
||||
<span className="zoom-icon-wrapper"><BiZoomIn /></span>
|
||||
<div className={styles.zoomControlsContainer}>
|
||||
<button onClick={handleZoomIn} className={styles.zoomBtn} title="Acercar">
|
||||
<span className={styles.zoomIconWrapper}><BiZoomIn /></span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className={`zoom-btn ${isZoomOutDisabled ? 'disabled' : ''}`}
|
||||
className={`${styles.zoomBtn} ${isZoomOutDisabled ? styles.disabled : ''}`}
|
||||
title="Alejar"
|
||||
disabled={isZoomOutDisabled}
|
||||
>
|
||||
<span className="zoom-icon-wrapper"><BiZoomOut /></span>
|
||||
<span className={styles.zoomIconWrapper}><BiZoomOut /></span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nivel !== 'pais' && <button onClick={onVolver} className="mapa-volver-btn">← Volver</button>}
|
||||
{nivel !== 'pais' && <button onClick={onVolver} className={styles.mapaVolverBtn}>← Volver</button>}
|
||||
|
||||
<div className="mapa-render-area">
|
||||
<div className={styles.mapaRenderArea}>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={isMobileView ? mobileProjectionConfig : desktopProjectionConfig}
|
||||
projectionConfig={isMobileSmall ? mobileSmallProjectionConfig : (isMobileView ? mobileProjectionConfig : desktopProjectionConfig)}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<ZoomableGroup
|
||||
@@ -263,12 +273,12 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
onMoveStart={() => setIsPanning(true)}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
filterZoomEvent={filterInteractionEvents}
|
||||
className={isPanning ? 'panning' : ''}
|
||||
className={isPanning ? styles.panning : ''}
|
||||
>
|
||||
<Geographies geography={geoDataNacional}>
|
||||
{({ geographies }: { geographies: AmbitoGeography[] }) => geographies.map((geo) => {
|
||||
const nombreNormalizado = normalizarTexto(geo.properties.nombre);
|
||||
const esCABA = nombreNormalizado === 'CIUDAD AUTONOMA DE BUENOS AIRES';
|
||||
const esCABA = nombreNormalizado === 'CABA';
|
||||
const resultado = resultadosNacionalesPorNombre.get(nombreNormalizado);
|
||||
const esProvinciaActiva = provinciaDistritoId && resultado?.ambitoId === provinciaDistritoId;
|
||||
|
||||
@@ -277,6 +287,8 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
ref={esCABA ? cabaPathRef : undefined}
|
||||
// 3. Las clases de react-simple-maps ahora deben ser globales o no funcionarán
|
||||
// Como el CSS module ya las define con :global(), no necesitamos hacer nada aquí.
|
||||
className={`rsm-geography ${nivel !== 'pais' ? 'rsm-geography-faded' : ''}`}
|
||||
style={{ visibility: esCABA ? 'hidden' : (esProvinciaActiva ? 'hidden' : 'visible') }}
|
||||
fill={nivel === 'pais' ? (resultado?.colorGanador || DEFAULT_MAP_COLOR) : FADED_BACKGROUND_COLOR}
|
||||
@@ -307,9 +319,9 @@ export const MapaNacional = ({ eleccionId, categoriaId, nivel, nombreAmbito, nom
|
||||
</div>
|
||||
|
||||
{nivel === 'pais' && (
|
||||
<div id="caba-lupa-anchor" className="caba-magnifier-container" style={lupaStyle} ref={lupaRef}>
|
||||
<div id="caba-lupa-anchor" className={styles.cabaMagnifierContainer} style={lupaStyle} ref={lupaRef}>
|
||||
{(() => {
|
||||
const resultadoCABA = resultadosNacionalesPorNombre.get("CIUDAD AUTONOMA DE BUENOS AIRES");
|
||||
const resultadoCABA = resultadosNacionalesPorNombre.get("CABA");
|
||||
const fillColor = resultadoCABA?.colorGanador || DEFAULT_MAP_COLOR;
|
||||
const handleClick = () => {
|
||||
if (resultadoCABA) {
|
||||
|
||||
@@ -31,6 +31,7 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
|
||||
const response = await axios.get(url);
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: geoData } = useSuspenseQuery<any>({
|
||||
@@ -42,7 +43,6 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
|
||||
},
|
||||
});
|
||||
|
||||
// useEffect que calcula y exporta la posición del municipio al padre
|
||||
useEffect(() => {
|
||||
if (nivel === 'municipio' && geoData?.objects && nombreMunicipioSeleccionado) {
|
||||
const geometries = geoData.objects[Object.keys(geoData.objects)[0]].geometries;
|
||||
@@ -50,14 +50,17 @@ export const MapaProvincial = ({ eleccionId, categoriaId, distritoId, nombreProv
|
||||
if (municipioGeo) {
|
||||
const municipioFeature = feature(geoData, municipioGeo);
|
||||
const centroid = geoCentroid(municipioFeature);
|
||||
// Llama a la función del padre para que actualice la posición
|
||||
onCalculatedCenter(centroid as PointTuple, 40);
|
||||
if (nombreProvincia.toUpperCase() === 'CABA') {
|
||||
onCalculatedCenter(centroid as PointTuple, 180);
|
||||
} else {
|
||||
onCalculatedCenter(centroid as PointTuple, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [nivel, nombreMunicipioSeleccionado, geoData, onCalculatedCenter]);
|
||||
|
||||
const resultadosPorNombre = new Map<string, ResultadoMapaDto>(mapaData.map(d => [normalizarTexto(d.ambitoNombre), d]));
|
||||
const esCABA = normalizarTexto(nombreProvincia) === "CIUDAD AUTONOMA DE BUENOS AIRES";
|
||||
const esCABA = normalizarTexto(nombreProvincia) === "CABA";
|
||||
|
||||
return (
|
||||
<Geographies geography={geoData}>
|
||||
|
||||
@@ -3,61 +3,54 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useMemo } from 'react';
|
||||
import { assetBaseUrl } from '../../../../apiService';
|
||||
// 1. Importamos el archivo de estilos como un módulo CSS
|
||||
import styles from '../ResultadosNacionalesCardsWidget.module.css';
|
||||
|
||||
interface MiniMapaSvgProps {
|
||||
provinciaNombre: string;
|
||||
fillColor: string;
|
||||
}
|
||||
|
||||
// Función para normalizar el nombre de la provincia y que coincida con el nombre del archivo SVG
|
||||
const normalizarNombreParaUrl = (nombre: string) =>
|
||||
nombre
|
||||
.toLowerCase()
|
||||
.replace(/ /g, '_') // Reemplaza espacios con guiones bajos
|
||||
.normalize("NFD") // Descompone acentos para eliminarlos en el siguiente paso
|
||||
.replace(/[\u0300-\u036f]/g, ""); // Elimina los acentos
|
||||
.replace(/ /g, '_')
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
|
||||
export const MiniMapaSvg = ({ provinciaNombre, fillColor }: MiniMapaSvgProps) => {
|
||||
const nombreNormalizado = normalizarNombreParaUrl(provinciaNombre);
|
||||
// Asumimos que los SVGs están en /public/maps/provincias-svg/
|
||||
const mapFileUrl = `${assetBaseUrl}/maps/provincias-svg/${nombreNormalizado}.svg`;
|
||||
|
||||
// Usamos React Query para fetchear el contenido del SVG como texto
|
||||
const { data: svgContent, isLoading, isError } = useQuery<string>({
|
||||
queryKey: ['svgMapa', nombreNormalizado],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(mapFileUrl, { responseType: 'text' });
|
||||
return response.data;
|
||||
},
|
||||
staleTime: Infinity, // Estos archivos son estáticos y no cambian
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
retry: false, // No reintentar si el archivo no existe
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// Usamos useMemo para modificar el SVG solo cuando el contenido o el color cambian
|
||||
const modifiedSvg = useMemo(() => {
|
||||
if (!svgContent) return '';
|
||||
|
||||
// Usamos una expresión regular para encontrar todas las etiquetas <path>
|
||||
// y añadirles el atributo de relleno con el color del ganador.
|
||||
// Esto sobrescribirá cualquier 'fill' que ya exista en la etiqueta.
|
||||
return svgContent.replace(/<path/g, `<path fill="${fillColor}"`);
|
||||
}, [svgContent, fillColor]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="map-placeholder" />;
|
||||
// 2. Usamos el objeto 'styles' para las clases
|
||||
return <div className={styles.mapPlaceholder} />;
|
||||
}
|
||||
|
||||
if (isError || !modifiedSvg) {
|
||||
// Muestra un placeholder si el SVG no se encontró o está vacío
|
||||
return <div className="map-placeholder error" />;
|
||||
// 3. Combinamos clases del módulo para el estado de error
|
||||
return <div className={`${styles.mapPlaceholder} ${styles.error}`} />;
|
||||
}
|
||||
|
||||
// Renderizamos el SVG modificado. dangerouslySetInnerHTML es seguro aquí
|
||||
// porque el contenido proviene de nuestros propios archivos SVG estáticos.
|
||||
return (
|
||||
<div
|
||||
className="map-svg-container"
|
||||
className={styles.mapSvgContainer}
|
||||
dangerouslySetInnerHTML={{ __html: modifiedSvg }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// src/features/legislativas/nacionales/components/MunicipioSearch.tsx
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Select, { type SingleValue } from 'react-select';
|
||||
import { getMunicipiosPorDistrito } from '../../../../apiService';
|
||||
import type { CatalogoItem } from '../../../../types/types';
|
||||
// 1. Importamos el archivo de estilos como un módulo CSS
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
interface MunicipioSearchProps {
|
||||
distritoId: string;
|
||||
onMunicipioSelect: (municipioId: string, municipioNombre: string) => void;
|
||||
}
|
||||
|
||||
interface OptionType {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 2. Definimos los estilos custom para react-select.
|
||||
// Ya no son necesarios aquí porque los definimos en el CSS module.
|
||||
// const customSelectStyles: StylesConfig<OptionType, false> = { ... };
|
||||
|
||||
export const MunicipioSearch = ({ distritoId, onMunicipioSelect }: MunicipioSearchProps) => {
|
||||
const [selectedOption, setSelectedOption] = useState<SingleValue<OptionType>>(null);
|
||||
|
||||
const { data: municipios = [], isLoading } = useQuery<CatalogoItem[]>({
|
||||
queryKey: ['municipiosPorDistrito', distritoId],
|
||||
queryFn: () => getMunicipiosPorDistrito(distritoId),
|
||||
enabled: !!distritoId,
|
||||
});
|
||||
|
||||
const options: OptionType[] = municipios.map(m => ({
|
||||
value: m.id,
|
||||
label: m.nombre
|
||||
}));
|
||||
|
||||
const handleChange = (selected: SingleValue<OptionType>) => {
|
||||
if (selected) {
|
||||
onMunicipioSelect(selected.value, selected.label);
|
||||
setSelectedOption(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Aplicamos la clase del módulo al contenedor principal
|
||||
// y usamos el classNamePrefix para que react-select genere clases
|
||||
// que podamos estilizar desde el CSS module.
|
||||
return (
|
||||
<div className={styles.municipioSearchContainer}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={handleChange}
|
||||
value={selectedOption}
|
||||
isLoading={isLoading}
|
||||
placeholder="Buscar municipio..."
|
||||
isClearable
|
||||
isSearchable
|
||||
// El prefijo se mantiene igual al del selector de categorías.
|
||||
// Esto nos permite reutilizar los mismos estilos :global que ya definimos.
|
||||
classNamePrefix="categoriaSelector"
|
||||
noOptionsMessage={() => 'No se encontraron municipios'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { assetBaseUrl } from '../../../../apiService';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar';
|
||||
import 'react-circular-progressbar/dist/styles.css';
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ',')}%`;
|
||||
const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR');
|
||||
@@ -12,7 +13,6 @@ const formatVotes = (num: number) => Math.round(num).toLocaleString('es-AR');
|
||||
const SvgDefs = () => (
|
||||
<svg style={{ height: 0, width: 0, position: 'absolute' }}>
|
||||
<defs>
|
||||
{/* El gradiente ahora se define para que el color oscuro se mantenga en la segunda mitad del recorrido vertical */}
|
||||
<linearGradient id="participationGradient" gradientTransform="rotate(90)">
|
||||
<stop offset="0%" stopColor="#e0f3ffff" />
|
||||
<stop offset="100%" stopColor="#007bff" />
|
||||
@@ -32,76 +32,68 @@ interface PanelResultadosProps {
|
||||
|
||||
export const PanelResultados = ({ resultados, estadoRecuento }: PanelResultadosProps) => {
|
||||
return (
|
||||
<div className="panel-resultados">
|
||||
<div className={styles.panelResultados}>
|
||||
<SvgDefs />
|
||||
<div className="panel-estado-recuento">
|
||||
<div className="estado-item">
|
||||
<div className={styles.panelEstadoRecuento}>
|
||||
<div className={styles.estadoItem}>
|
||||
<CircularProgressbar
|
||||
value={estadoRecuento.participacionPorcentaje}
|
||||
text={formatPercent(estadoRecuento.participacionPorcentaje)}
|
||||
strokeWidth={12}
|
||||
circleRatio={0.75} /* Se convierte en un arco de 270 grados */
|
||||
styles={buildStyles({
|
||||
textColor: '#333',
|
||||
pathColor: 'url(#participationGradient)',
|
||||
trailColor: '#e9ecef',
|
||||
textSize: '22px',
|
||||
rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */
|
||||
})}
|
||||
circleRatio={0.75}
|
||||
styles={buildStyles({ textColor: '#333', pathColor: 'url(#participationGradient)', trailColor: '#e9ecef', textSize: '22px', rotation: 0.625, })}
|
||||
/>
|
||||
<span>Participación</span>
|
||||
</div>
|
||||
<div className="estado-item">
|
||||
<div className={styles.estadoItem}>
|
||||
<CircularProgressbar
|
||||
value={estadoRecuento.mesasTotalizadasPorcentaje}
|
||||
text={formatPercent(estadoRecuento.mesasTotalizadasPorcentaje)}
|
||||
strokeWidth={12}
|
||||
circleRatio={0.75} /* Se convierte en un arco de 270 grados */
|
||||
styles={buildStyles({
|
||||
textColor: '#333',
|
||||
pathColor: 'url(#scrutinizedGradient)',
|
||||
trailColor: '#e9ecef',
|
||||
textSize: '22px',
|
||||
rotation: 0.625, /* Rota el inicio para que la apertura quede abajo */
|
||||
})}
|
||||
circleRatio={0.75}
|
||||
styles={buildStyles({ textColor: '#333', pathColor: 'url(#scrutinizedGradient)', trailColor: '#e9ecef', textSize: '22px', rotation: 0.625, })}
|
||||
/>
|
||||
<span>Escrutado</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-partidos-container">
|
||||
<div className={styles.panelPartidosContainer}>
|
||||
{resultados.map(partido => (
|
||||
<div
|
||||
key={partido.id}
|
||||
className="partido-fila"
|
||||
style={{ borderLeftColor: partido.color || '#ccc' }}
|
||||
>
|
||||
<div className="partido-logo">
|
||||
<div key={partido.id} className={styles.partidoFila} style={{ borderLeftColor: partido.color || '#ccc' }}>
|
||||
<div className={styles.partidoLogo} style={{ backgroundColor: partido.color || '#e9ecef' }}>
|
||||
<ImageWithFallback src={partido.logoUrl || undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={partido.nombre} />
|
||||
</div>
|
||||
<div className="partido-main-content">
|
||||
<div className="partido-top-row">
|
||||
<div className="partido-info-wrapper">
|
||||
<div className={styles.partidoMainContent}>
|
||||
<div className={styles.partidoTopRow}>
|
||||
<div className={styles.partidoInfoWrapper}>
|
||||
{partido.nombreCandidato ? (
|
||||
<>
|
||||
<span className="candidato-nombre">{partido.nombreCandidato}</span>
|
||||
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className={styles.candidatoNombre}>{partido.nombreCandidato}</span>
|
||||
<span className={styles.partidoNombreNormal}>{partido.nombreCorto || partido.nombre}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="partido-nombre">{partido.nombreCorto || partido.nombre}</span>
|
||||
<span className={styles.partidoNombre}>{partido.nombreCorto || partido.nombre}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="partido-stats">
|
||||
<span className="partido-porcentaje">
|
||||
<div className={styles.partidoStats}>
|
||||
<span className={styles.partidoPorcentaje}>
|
||||
<AnimatedNumber value={partido.porcentaje} formatter={formatPercent} />
|
||||
</span>
|
||||
<span className="partido-votos">
|
||||
<AnimatedNumber value={partido.votos} formatter={formatVotes} /> votos
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partido-barra-background">
|
||||
<div className="partido-barra-foreground" style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }} />
|
||||
|
||||
<div className={styles.partidoBarraConVotos}>
|
||||
<div
|
||||
className={styles.partidoBarraForeground}
|
||||
style={{ width: `${partido.porcentaje}%`, backgroundColor: partido.color || '#888' }}
|
||||
/>
|
||||
<span className={styles.partidoVotosEnBarra}>
|
||||
<span className={styles.animatedNumberWrapper}>
|
||||
<AnimatedNumber value={partido.votos} formatter={formatVotes} />
|
||||
</span>
|
||||
Votos
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
// src/features/legislativas/nacionales/components/PanelResultadosSkeleton.tsx
|
||||
import styles from '../PanelNacional.module.css';
|
||||
|
||||
const SkeletonRow = () => (
|
||||
<div className="partido-fila skeleton-fila">
|
||||
<div className="skeleton-logo" />
|
||||
<div className="partido-info-wrapper">
|
||||
<div className="skeleton-text" style={{ width: '60%' }} />
|
||||
<div className="skeleton-text" style={{ width: '40%', marginTop: '4px' }} />
|
||||
<div className="skeleton-bar" />
|
||||
</div>
|
||||
<div className="partido-stats">
|
||||
<div className="skeleton-text" style={{ width: '70%', marginBottom: '4px' }} />
|
||||
<div className="skeleton-text" style={{ width: '50%' }} />
|
||||
<div className={`${styles.partidoFila} ${styles.skeletonFila}`}>
|
||||
<div className={styles.skeletonLogo} />
|
||||
<div className={styles.partidoMainContent}>
|
||||
{/* Fila Superior (Nombre y Porcentaje) */}
|
||||
<div className={styles.partidoInfoWrapper}>
|
||||
<div className={styles.skeletonText} style={{ width: '70%' }} />
|
||||
<div className={styles.skeletonText} style={{ width: '50%', marginTop: '4px' }} />
|
||||
</div>
|
||||
<div className={styles.partidoStats}>
|
||||
<div className={styles.skeletonText} style={{ width: '80px' }} />
|
||||
</div>
|
||||
|
||||
{/* --- CAMBIO: La barra ahora es más alta y no hay texto de votos debajo --- */}
|
||||
<div
|
||||
className={styles.skeletonBar}
|
||||
style={{ gridColumn: '1 / 3', height: '28px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PanelResultadosSkeleton = () => (
|
||||
<div className="panel-resultados-skeleton">
|
||||
<div className={styles.panelResultadosSkeleton}>
|
||||
{[...Array(5)].map((_, i) => <SkeletonRow key={i} />)}
|
||||
</div>
|
||||
);
|
||||
@@ -3,8 +3,8 @@ import type { ResumenProvincia, CategoriaResumen } from '../../../../types/types
|
||||
import { MiniMapaSvg } from './MiniMapaSvg';
|
||||
import { ImageWithFallback } from '../../../../components/common/ImageWithFallback';
|
||||
import { assetBaseUrl } from '../../../../apiService';
|
||||
import styles from '../ResultadosNacionalesCardsWidget.module.css';
|
||||
|
||||
// --- 1. AÑADIR LA PROP A AMBAS INTERFACES ---
|
||||
interface CategoriaDisplayProps {
|
||||
categoria: CategoriaResumen;
|
||||
mostrarBancas?: boolean;
|
||||
@@ -18,45 +18,46 @@ interface ProvinciaCardProps {
|
||||
const formatNumber = (num: number) => num.toLocaleString('es-AR');
|
||||
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
|
||||
|
||||
// --- 2. RECIBIR Y USAR LA PROP EN EL SUB-COMPONENTE ---
|
||||
const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) => {
|
||||
return (
|
||||
<div className="categoria-bloque">
|
||||
<h4 className="categoria-titulo">{categoria.categoriaNombre}</h4>
|
||||
<div className={styles.categoriaBloque}>
|
||||
<h4 className={styles.categoriaTitulo}>{categoria.categoriaNombre}</h4>
|
||||
|
||||
{categoria.resultados.map(res => (
|
||||
<div
|
||||
key={res.agrupacionId}
|
||||
className="candidato-row"
|
||||
className={styles.candidatoRow}
|
||||
style={{ borderLeftColor: res.color || '#ccc' }}
|
||||
>
|
||||
<ImageWithFallback
|
||||
src={res.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={res.nombreCandidato ?? res.nombreAgrupacion}
|
||||
className="candidato-foto"
|
||||
/>
|
||||
<div className={styles.candidatoFotoWrapper} style={{ backgroundColor: res.color || '#e9ecef' }}>
|
||||
<ImageWithFallback
|
||||
src={res.fotoUrl ?? undefined}
|
||||
fallbackSrc={`${assetBaseUrl}/default-avatar.png`}
|
||||
alt={res.nombreCandidato ?? res.nombreAgrupacion}
|
||||
className={styles.candidatoFoto}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="candidato-data">
|
||||
{res.nombreCandidato && (
|
||||
<span className="candidato-nombre">{res.nombreCandidato}</span>
|
||||
<div className={styles.candidatoData}>
|
||||
{res.nombreCandidato ? (
|
||||
<>
|
||||
<span className={styles.candidatoNombre}>{res.nombreCandidato}</span>
|
||||
<span className={styles.candidatoPartido}>{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.candidatoNombre}>{res.nombreCortoAgrupacion || res.nombreAgrupacion}</span>
|
||||
)}
|
||||
<span className={`candidato-partido ${!res.nombreCandidato ? 'main-title' : ''}`}>
|
||||
{res.nombreAgrupacion}
|
||||
</span>
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} />
|
||||
<div className={styles.progressBarContainer}>
|
||||
<div className={styles.progressBar} style={{ width: `${res.porcentaje}%`, backgroundColor: res.color || '#ccc' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="candidato-stats">
|
||||
<span className="stats-percent">{formatPercent(res.porcentaje)}</span>
|
||||
<span className="stats-votos">{formatNumber(res.votos)} votos</span>
|
||||
<div className={styles.candidatoStats}>
|
||||
<span className={styles.statsPercent}>{formatPercent(res.porcentaje)}</span>
|
||||
<span className={styles.statsVotos}>{formatNumber(res.votos)} votos</span>
|
||||
</div>
|
||||
|
||||
{/* --- 3. RENDERIZADO CONDICIONAL DEL CUADRO DE BANCAS --- */}
|
||||
{/* Este div solo se renderizará si mostrarBancas es true */}
|
||||
{mostrarBancas && (
|
||||
<div className="stats-bancas">
|
||||
<div className={styles.statsBancas}>
|
||||
+{res.bancasObtenidas}
|
||||
<span>Bancas</span>
|
||||
</div>
|
||||
@@ -64,7 +65,7 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
|
||||
</div>
|
||||
))}
|
||||
|
||||
<footer className="card-footer">
|
||||
<footer className={styles.cardFooter}>
|
||||
<div>
|
||||
<span>Participación</span>
|
||||
<strong>{formatPercent(categoria.estadoRecuento?.participacionPorcentaje ?? 0)}</strong>
|
||||
@@ -82,26 +83,24 @@ const CategoriaDisplay = ({ categoria, mostrarBancas }: CategoriaDisplayProps) =
|
||||
);
|
||||
};
|
||||
|
||||
// --- 4. RECIBIR Y PASAR LA PROP EN EL COMPONENTE PRINCIPAL ---
|
||||
export const ProvinciaCard = ({ data, mostrarBancas }: ProvinciaCardProps) => {
|
||||
const colorGanador = data.categorias[0]?.resultados[0]?.color || '#d1d1d1';
|
||||
|
||||
return (
|
||||
<div className="provincia-card">
|
||||
<header className="card-header">
|
||||
<div className="header-info">
|
||||
<div className={styles.provinciaCard}>
|
||||
<header className={styles.cardHeader}>
|
||||
<div className={styles.headerInfo}>
|
||||
<h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3>
|
||||
</div>
|
||||
<div className="header-map">
|
||||
<div className={styles.headerMap}>
|
||||
<MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} />
|
||||
</div>
|
||||
</header>
|
||||
<div className="card-body">
|
||||
<div className={styles.cardBody}>
|
||||
{data.categorias.map(categoria => (
|
||||
<CategoriaDisplay
|
||||
key={categoria.categoriaId}
|
||||
categoria={categoria}
|
||||
mostrarBancas={mostrarBancas} // Pasar la prop hacia abajo
|
||||
mostrarBancas={mostrarBancas}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export const BancasWidget = () => {
|
||||
error
|
||||
} = useQuery<ProyeccionBancas, Error>({
|
||||
queryKey: ['bancasPorSeccion', selectedSeccion?.value, camaraActiva],
|
||||
queryFn: () => getBancasPorSeccion(selectedSeccion!.value, camaraActiva),
|
||||
queryFn: () => getBancasPorSeccion(1,selectedSeccion!.value, camaraActiva),
|
||||
enabled: !!selectedSeccion && camarasDisponibles.includes(camaraActiva),
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error.response?.status === 404) return false;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ConcejalesPorSeccionWidget = () => {
|
||||
// Query para obtener los resultados de la sección seleccionada
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorSeccion(1, selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const CATEGORIA_ID = 7; // ID para Concejales
|
||||
export const ConcejalesTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
queryFn: () => getResumenProvincial(1),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
@@ -33,9 +33,25 @@ export const ConcejalesTickerWidget = () => {
|
||||
if (error || !ConcejalesData) return <div className="ticker-card error"><p>Datos de Concejales no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = ConcejalesData.resultados;
|
||||
let displayResults: ResultadoTicker[] = ConcejalesData.resultados.map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto || r.nombre,
|
||||
color: r.color,
|
||||
logoUrl: r.logoUrl,
|
||||
votos: r.votos,
|
||||
porcentaje: r.porcentaje,
|
||||
}));
|
||||
if (ConcejalesData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = ConcejalesData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const topParties = ConcejalesData.resultados.slice(0, cantidadAMostrar - 1).map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto || r.nombre,
|
||||
color: r.color,
|
||||
logoUrl: r.logoUrl,
|
||||
votos: r.votos,
|
||||
porcentaje: r.porcentaje,
|
||||
}));
|
||||
const otherParties = ConcejalesData.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
const otrosEntry: ResultadoTicker = {
|
||||
@@ -49,7 +65,15 @@ export const ConcejalesTickerWidget = () => {
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = ConcejalesData.resultados.slice(0, cantidadAMostrar);
|
||||
displayResults = ConcejalesData.resultados.slice(0, cantidadAMostrar).map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto || r.nombre,
|
||||
color: r.color,
|
||||
logoUrl: r.logoUrl,
|
||||
votos: r.votos,
|
||||
porcentaje: r.porcentaje,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ConcejalesWidget = () => {
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosPorMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorMunicipio(1,selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ','
|
||||
export const DipSenTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
queryFn: () => getResumenProvincial(1),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
@@ -34,10 +34,26 @@ export const DipSenTickerWidget = () => {
|
||||
<div className="ticker-wrapper">
|
||||
{categoriasFiltradas.map(categoria => {
|
||||
|
||||
let displayResults: ResultadoTicker[] = categoria.resultados;
|
||||
let displayResults: ResultadoTicker[] = categoria.resultados.map((r: any) => ({
|
||||
id: r.id ?? r.candidatoId ?? '',
|
||||
nombre: r.nombre ?? r.candidatoNombre ?? '',
|
||||
nombreCorto: r.nombreCorto ?? r.candidatoNombreCorto ?? r.nombre ?? '',
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
|
||||
if (categoria.resultados.length > cantidadAMostrar) {
|
||||
const topParties = categoria.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const topParties = categoria.resultados.slice(0, cantidadAMostrar - 1).map((r: any) => ({
|
||||
id: r.id ?? r.candidatoId ?? '',
|
||||
nombre: r.nombre ?? r.candidatoNombre ?? '',
|
||||
nombreCorto: r.nombreCorto ?? r.candidatoNombreCorto ?? r.nombre ?? '',
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
const otherParties = categoria.resultados.slice(cantidadAMostrar - 1);
|
||||
const otrosPorcentaje = otherParties.reduce((sum, party) => sum + party.porcentaje, 0);
|
||||
|
||||
@@ -53,7 +69,15 @@ export const DipSenTickerWidget = () => {
|
||||
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
} else {
|
||||
displayResults = categoria.resultados.slice(0, cantidadAMostrar);
|
||||
displayResults = categoria.resultados.slice(0, cantidadAMostrar).map((r: any) => ({
|
||||
id: r.id ?? r.candidatoId ?? '',
|
||||
nombre: r.nombre ?? r.candidatoNombre ?? '',
|
||||
nombreCorto: r.nombreCorto ?? r.candidatoNombreCorto ?? r.nombre ?? '',
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ export const DiputadosPorSeccionWidget = () => {
|
||||
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorSeccion(1,selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const CATEGORIA_ID = 6; // ID para Diputados
|
||||
export const DiputadosTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
queryFn: () => getResumenProvincial(1),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,15 @@ export const DiputadosTickerWidget = () => {
|
||||
if (error || !diputadosData) return <div className="ticker-card error"><p>Datos de Diputados no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = diputadosData.resultados;
|
||||
let displayResults: ResultadoTicker[] = diputadosData.resultados.map((r: any) => ({
|
||||
id: r.id ?? '',
|
||||
nombre: r.nombre ?? '',
|
||||
nombreCorto: r.nombreCorto ?? r.nombre ?? '',
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
if (diputadosData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = diputadosData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = diputadosData.resultados.slice(cantidadAMostrar - 1);
|
||||
@@ -47,9 +55,28 @@ export const DiputadosTickerWidget = () => {
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
displayResults = [
|
||||
...topParties.map(party => ({
|
||||
id: party.agrupacionId ?? '',
|
||||
nombre: party.nombreCandidato ?? '',
|
||||
nombreCorto: party.nombreCortoAgrupacion ?? party.nombreCortoAgrupacion ?? '',
|
||||
color: party.color ?? '#888888',
|
||||
logoUrl: party.fotoUrl ?? null,
|
||||
votos: party.votos ?? 0,
|
||||
porcentaje: party.porcentaje ?? 0,
|
||||
})),
|
||||
otrosEntry
|
||||
];
|
||||
} else {
|
||||
displayResults = diputadosData.resultados.slice(0, cantidadAMostrar);
|
||||
displayResults = diputadosData.resultados.slice(0, cantidadAMostrar).map((r: any) => ({
|
||||
id: r.id ?? '',
|
||||
nombre: r.nombre ?? '',
|
||||
nombreCorto: r.nombreCorto ?? r.nombre ?? '',
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,7 @@ export const DiputadosWidget = () => {
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorMunicipio(1,selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const DetalleSeccion = ({ seccion, categoriaId, onReset }: { seccion: SeccionGeo
|
||||
|
||||
const { data: resultadosDetalle, isLoading, error } = useQuery<ResultadoDetalleSeccion[]>({
|
||||
queryKey: ['detalleSeccion', seccionId, categoriaId],
|
||||
queryFn: () => getDetalleSeccion(seccionId!, categoriaId),
|
||||
queryFn: () => getDetalleSeccion(1,seccionId!, categoriaId),
|
||||
enabled: !!seccionId,
|
||||
});
|
||||
|
||||
|
||||
@@ -144,8 +144,24 @@ export const ResultadosRankingMunicipioWidget = () => {
|
||||
<td className="sticky-col">{municipio.municipioNombre}</td>
|
||||
{rankingData.categorias.flatMap(cat => {
|
||||
const resCategoria = municipio.resultadosPorCategoria[cat.id];
|
||||
const primerPuesto = resCategoria?.ranking[0];
|
||||
const segundoPuesto = resCategoria?.ranking[1];
|
||||
const primerPuestoRaw = resCategoria?.ranking[0];
|
||||
const segundoPuestoRaw = resCategoria?.ranking[1];
|
||||
|
||||
// Asegurarse que los objetos tengan la propiedad 'votos'
|
||||
const primerPuesto = primerPuestoRaw
|
||||
? {
|
||||
nombreCorto: primerPuestoRaw.nombreCorto,
|
||||
porcentaje: primerPuestoRaw.porcentaje,
|
||||
votos: 'votos' in primerPuestoRaw ? (primerPuestoRaw as any).votos : 0
|
||||
}
|
||||
: undefined;
|
||||
const segundoPuesto = segundoPuestoRaw
|
||||
? {
|
||||
nombreCorto: segundoPuestoRaw.nombreCorto,
|
||||
porcentaje: segundoPuestoRaw.porcentaje,
|
||||
votos: 'votos' in segundoPuestoRaw ? (segundoPuestoRaw as any).votos : 0
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return [
|
||||
// --- Celdas para el 1° Puesto ---
|
||||
|
||||
@@ -11,7 +11,7 @@ const formatPercent = (num: number) => `${(num || 0).toFixed(2).replace('.', ','
|
||||
export const ResumenGeneralWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
queryFn: () => getResumenProvincial(1),
|
||||
refetchInterval: 180000,
|
||||
});
|
||||
|
||||
@@ -33,12 +33,19 @@ export const ResumenGeneralWidget = () => {
|
||||
|
||||
legislativeCategories.forEach(category => {
|
||||
category.resultados.forEach(party => {
|
||||
const existing = partyMap.get(party.id);
|
||||
const existing = partyMap.get(party.agrupacionId);
|
||||
if (existing) {
|
||||
existing.votos += party.votos;
|
||||
} else {
|
||||
// Clonamos el objeto para no modificar el original
|
||||
partyMap.set(party.id, { ...party });
|
||||
// Clonamos el objeto para no modificar el original y adaptamos las propiedades
|
||||
partyMap.set(party.agrupacionId, {
|
||||
id: party.agrupacionId,
|
||||
nombre: party.nombreAgrupacion,
|
||||
nombreCorto: party.nombreCortoAgrupacion ?? party.nombreAgrupacion,
|
||||
logoUrl: party.fotoUrl,
|
||||
color: party.color,
|
||||
votos: party.votos,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SenadoresPorSeccionWidget = () => {
|
||||
|
||||
const { data, isLoading: isLoadingResultados } = useQuery<ApiResponseResultadosPorSeccion>({
|
||||
queryKey: ['resultadosPorSeccion', selectedSeccion?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorSeccion(selectedSeccion!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorSeccion(1,selectedSeccion!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedSeccion,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const CATEGORIA_ID = 5; // ID para Senadores
|
||||
export const SenadoresTickerWidget = () => {
|
||||
const { data: categorias, isLoading, error } = useQuery<CategoriaResumen[]>({
|
||||
queryKey: ['resumenProvincial'],
|
||||
queryFn: getResumenProvincial,
|
||||
queryFn: () => getResumenProvincial(1),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
@@ -33,7 +33,15 @@ export const SenadoresTickerWidget = () => {
|
||||
if (error || !senadoresData) return <div className="ticker-card error"><p>Datos de Senadores no disponibles.</p></div>;
|
||||
|
||||
// Lógica para "Otros"
|
||||
let displayResults: ResultadoTicker[] = senadoresData.resultados;
|
||||
let displayResults: ResultadoTicker[] = senadoresData.resultados.map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto ?? r.nombre,
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
if (senadoresData.resultados.length > cantidadAMostrar) {
|
||||
const topParties = senadoresData.resultados.slice(0, cantidadAMostrar - 1);
|
||||
const otherParties = senadoresData.resultados.slice(cantidadAMostrar - 1);
|
||||
@@ -47,9 +55,28 @@ export const SenadoresTickerWidget = () => {
|
||||
votos: 0,
|
||||
porcentaje: otrosPorcentaje,
|
||||
};
|
||||
displayResults = [...topParties, otrosEntry];
|
||||
displayResults = [
|
||||
...topParties.map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto ?? r.nombre,
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
})),
|
||||
otrosEntry
|
||||
];
|
||||
} else {
|
||||
displayResults = senadoresData.resultados.slice(0, cantidadAMostrar);
|
||||
displayResults = senadoresData.resultados.slice(0, cantidadAMostrar).map((r: any) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
nombreCorto: r.nombreCorto ?? r.nombre,
|
||||
color: r.color ?? '#888888',
|
||||
logoUrl: r.logoUrl ?? null,
|
||||
votos: r.votos ?? 0,
|
||||
porcentaje: r.porcentaje ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -54,7 +54,7 @@ export const SenadoresWidget = () => {
|
||||
|
||||
const { data: resultados, isLoading: isLoadingResultados } = useQuery<ResultadoTicker[]>({
|
||||
queryKey: ['resultadosMunicipio', selectedMunicipio?.value, CATEGORIA_ID],
|
||||
queryFn: () => getResultadosPorMunicipio(selectedMunicipio!.value, CATEGORIA_ID),
|
||||
queryFn: () => getResultadosPorMunicipio(1,selectedMunicipio!.value, CATEGORIA_ID),
|
||||
enabled: !!selectedMunicipio,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/* src/index.css */
|
||||
|
||||
/* Se importa la tipografía "Public Sans" desde Google Fonts, la misma que usa eldia.com */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
/* Se establece la nueva fuente principal y las de respaldo */
|
||||
font-family: "Public Sans", system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
/* Tema Claro inspirado en El Día */
|
||||
color-scheme: light;
|
||||
color: #333333; /* Texto principal más oscuro para mejor legibilidad */
|
||||
background-color: #ffffff; /* Fondo blanco */
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #0073e6; /* Azul corporativo de El Día */
|
||||
text-decoration: none; /* Se quita el subrayado por defecto, como en el sitio */
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline; /* Se añade subrayado en hover para claridad */
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
/* Se elimina el centrado vertical para un layout de página más tradicional */
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5em; /* Ligeramente más pequeño para un look más de noticia */
|
||||
line-height: 1.1;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 4px; /* Bordes menos redondeados */
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #f8f9fa; /* Un gris muy claro para botones */
|
||||
border-color: #dee2e6; /* Borde sutil */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #0073e6;
|
||||
background-color: #eef7ff;
|
||||
}
|
||||
@@ -27,8 +27,13 @@ import { ResultadosRankingMunicipioWidget } from './features/legislativas/provin
|
||||
import { HomeCarouselWidget } from './features/legislativas/nacionales/HomeCarouselWidget';
|
||||
import { PanelNacionalWidget } from './features/legislativas/nacionales/PanelNacionalWidget';
|
||||
import { ResultadosNacionalesCardsWidget } from './features/legislativas/nacionales/ResultadosNacionalesCardsWidget';
|
||||
import { CongresoNacionalWidget } from './features/legislativas/nacionales/CongresoNacionalWidget';
|
||||
import { HomeCarouselNacionalWidget } from './features/legislativas/nacionales/HomeCarouselNacionalWidget';
|
||||
import { TablaConurbanoWidget } from './features/legislativas/nacionales/TablaConurbanoWidget';
|
||||
import { TablaSeccionesWidget } from './features/legislativas/nacionales/TablaSeccionesWidget';
|
||||
import { ResumenNacionalWidget } from './features/legislativas/nacionales/ResumenNacionalWidget';
|
||||
import { HomeCarouselProvincialWidget } from './features/legislativas/nacionales/HomeCarouselProvincialWidget';
|
||||
|
||||
import './index.css';
|
||||
import { DevAppLegislativas } from './features/legislativas/DevAppLegislativas';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -57,8 +62,14 @@ const WIDGET_MAP: Record<string, React.ElementType> = {
|
||||
|
||||
// Widgets Legislativas Nacionales 2025
|
||||
'home-carousel': HomeCarouselWidget,
|
||||
'home-carousel-nacional': HomeCarouselNacionalWidget,
|
||||
'panel-nacional': PanelNacionalWidget,
|
||||
'resultados-nacionales-cards': ResultadosNacionalesCardsWidget,
|
||||
'composicion-congreso-nacional': CongresoNacionalWidget,
|
||||
'tabla-conurbano': TablaConurbanoWidget,
|
||||
'tabla-secciones': TablaSeccionesWidget,
|
||||
'resumen-nacional': ResumenNacionalWidget,
|
||||
'home-carousel-provincial': HomeCarouselProvincialWidget,
|
||||
};
|
||||
|
||||
// Vite establece `import.meta.env.DEV` a `true` cuando ejecutamos 'npm run dev'
|
||||
@@ -83,9 +94,6 @@ if (import.meta.env.DEV) {
|
||||
if (widgetName && WIDGET_MAP[widgetName]) {
|
||||
const WidgetComponent = WIDGET_MAP[widgetName];
|
||||
const root = ReactDOM.createRoot(container);
|
||||
|
||||
// Pasamos todas las props (ej. { eleccionesWidget: '...', focoMunicipio: '...' })
|
||||
// al componente que se va a renderizar.
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface PanelElectoralDto {
|
||||
mapaData: ResultadoMapaDto[];
|
||||
resultadosPanel: ResultadoTicker[];
|
||||
estadoRecuento: EstadoRecuentoTicker;
|
||||
sinDatos?: boolean;
|
||||
}
|
||||
|
||||
// --- TIPOS PARA EL WIDGET DE TARJETAS NACIONALES ---
|
||||
@@ -151,7 +152,7 @@ export interface ResultadoCandidato {
|
||||
color: string | null;
|
||||
porcentaje: number;
|
||||
votos: number;
|
||||
bancasObtenidas?: number;
|
||||
bancasObtenidas?: number;
|
||||
}
|
||||
|
||||
// Definición para una categoría.
|
||||
@@ -170,12 +171,41 @@ export interface ResumenProvincia {
|
||||
}
|
||||
|
||||
export interface CategoriaResumenHome {
|
||||
categoriaId: number;
|
||||
categoriaNombre: string;
|
||||
estadoRecuento: EstadoRecuentoDto | null;
|
||||
resultados: ResultadoCandidato[];
|
||||
votosEnBlanco: number;
|
||||
votosEnBlancoPorcentaje: number;
|
||||
votosTotales: number;
|
||||
ultimaActualizacion: string;
|
||||
categoriaId: number;
|
||||
categoriaNombre: string;
|
||||
estadoRecuento: EstadoRecuentoDto | null;
|
||||
resultados: ResultadoCandidato[];
|
||||
votosEnBlanco: number;
|
||||
votosEnBlancoPorcentaje: number;
|
||||
votosTotales: number;
|
||||
ultimaActualizacion: string;
|
||||
}
|
||||
|
||||
// --- TIPOS PARA WIDGETS DE TABLAS ---
|
||||
export interface ResultadoFila {
|
||||
ambitoId: number;
|
||||
nombre: string;
|
||||
orden: number;
|
||||
fuerza1Display: string;
|
||||
fuerza1Porcentaje: number;
|
||||
fuerza2Display: string;
|
||||
fuerza2Porcentaje: number;
|
||||
}
|
||||
|
||||
export interface ResultadoSeccion {
|
||||
seccionId: string;
|
||||
nombre: string;
|
||||
municipios: ResultadoFila[];
|
||||
}
|
||||
|
||||
export interface PartidoResumen {
|
||||
nombre: string;
|
||||
porcentaje: number;
|
||||
}
|
||||
|
||||
export interface ProvinciaResumen {
|
||||
provinciaId: string;
|
||||
provinciaNombre: string;
|
||||
porcentajeEscrutado: number;
|
||||
resultados: PartidoResumen[];
|
||||
}
|
||||
@@ -398,20 +398,18 @@ public class AdminController : ControllerBase
|
||||
|
||||
// GUARDAR (Upsert) una lista de bancas previas
|
||||
[HttpPut("bancas-previas/{eleccionId}")]
|
||||
public async Task<IActionResult> UpdateBancasPrevias(int eleccionId, [FromBody] List<BancaPrevia> bancas)
|
||||
public async Task<IActionResult> UpdateBancasPrevias(int eleccionId, [FromBody] List<UpdateBancaPreviaDto> bancasDto)
|
||||
{
|
||||
// Borramos los registros existentes para esta elección para simplificar la lógica
|
||||
await _dbContext.BancasPrevias.Where(b => b.EleccionId == eleccionId).ExecuteDeleteAsync();
|
||||
|
||||
// Añadimos los nuevos registros que tienen al menos una banca
|
||||
foreach (var banca in bancas.Where(b => b.Cantidad > 0))
|
||||
foreach (var bancaDto in bancasDto.Where(b => b.Cantidad > 0))
|
||||
{
|
||||
_dbContext.BancasPrevias.Add(new BancaPrevia
|
||||
{
|
||||
EleccionId = eleccionId,
|
||||
Camara = banca.Camara,
|
||||
AgrupacionPoliticaId = banca.AgrupacionPoliticaId,
|
||||
Cantidad = banca.Cantidad
|
||||
Camara = bancaDto.Camara,
|
||||
AgrupacionPoliticaId = bancaDto.AgrupacionPoliticaId,
|
||||
Cantidad = bancaDto.Cantidad
|
||||
});
|
||||
}
|
||||
|
||||
@@ -419,4 +417,49 @@ public class AdminController : ControllerBase
|
||||
_logger.LogInformation("Se actualizaron las bancas previas para la EleccionId: {EleccionId}", eleccionId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Endpoint para crear una nueva agrupación política manualmente
|
||||
[HttpPost("agrupaciones")]
|
||||
public async Task<IActionResult> CreateAgrupacion([FromBody] CreateAgrupacionDto agrupacionDto)
|
||||
{
|
||||
// 1. Validar si ya existe una agrupación con el mismo nombre para evitar duplicados
|
||||
if (await _dbContext.AgrupacionesPoliticas.AnyAsync(a => a.Nombre == agrupacionDto.Nombre))
|
||||
{
|
||||
return BadRequest(new { message = $"Ya existe una agrupación con el nombre '{agrupacionDto.Nombre}'." });
|
||||
}
|
||||
|
||||
// 2. Lógica para generar el nuevo ID a partir de 10000
|
||||
var agrupacionesManualesIds = await _dbContext.AgrupacionesPoliticas
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync();
|
||||
|
||||
int maxId = 9999; // Empezamos justo antes de 10000
|
||||
foreach (var idStr in agrupacionesManualesIds)
|
||||
{
|
||||
if (int.TryParse(idStr, out int idNum) && idNum > maxId)
|
||||
{
|
||||
maxId = idNum;
|
||||
}
|
||||
}
|
||||
int nuevoId = maxId + 1;
|
||||
|
||||
// 3. Crear la nueva entidad
|
||||
var nuevaAgrupacion = new AgrupacionPolitica
|
||||
{
|
||||
Id = nuevoId.ToString(),
|
||||
Nombre = agrupacionDto.Nombre,
|
||||
NombreCorto = agrupacionDto.NombreCorto,
|
||||
Color = agrupacionDto.Color,
|
||||
IdTelegrama = $"MANUAL-{nuevoId}" // Asignamos un IdTelegrama distintivo
|
||||
};
|
||||
|
||||
// 4. Guardar en la base de datos
|
||||
await _dbContext.AgrupacionesPoliticas.AddAsync(nuevaAgrupacion);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Se creó una nueva agrupación manual: {Nombre} (ID: {Id})", nuevaAgrupacion.Nombre, nuevaAgrupacion.Id);
|
||||
|
||||
// 5. Devolver la entidad creada (buena práctica REST)
|
||||
return Ok(nuevaAgrupacion);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,23 @@ public class CatalogosController : ControllerBase
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene la lista de todas las provincias (distritos).
|
||||
/// </summary>
|
||||
[HttpGet("provincias")]
|
||||
public async Task<IActionResult> GetProvincias()
|
||||
{
|
||||
var provincias = await _dbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.Where(a => a.NivelId == 10 && !string.IsNullOrEmpty(a.DistritoId)) // Nivel 10 = Provincia
|
||||
.OrderBy(a => a.Nombre)
|
||||
.Select(a => new { Id = a.DistritoId, a.Nombre })
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(provincias);
|
||||
}
|
||||
|
||||
[HttpGet("municipios")]
|
||||
public async Task<IActionResult> GetMunicipios([FromQuery] int? categoriaId)
|
||||
{
|
||||
@@ -175,4 +192,20 @@ public class CatalogosController : ControllerBase
|
||||
|
||||
return Ok(establecimientos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Obtiene la lista de municipios (partidos) para un distrito (provincia) específico.
|
||||
/// </summary>
|
||||
[HttpGet("municipios-por-distrito/{distritoId}")]
|
||||
public async Task<IActionResult> GetMunicipiosPorDistrito(string distritoId)
|
||||
{
|
||||
var municipios = await _dbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.Where(a => a.NivelId == 30 && a.DistritoId == distritoId) // Nivel 30 = Municipio
|
||||
.OrderBy(a => a.Nombre)
|
||||
.Select(a => new { Id = a.Id.ToString(), a.Nombre }) // Devolvemos el ID de la BD como string
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(municipios);
|
||||
}
|
||||
}
|
||||
@@ -138,15 +138,13 @@ public class ResultadosController : ControllerBase
|
||||
var resultadosPorMunicipio = await _dbContext.ResultadosVotos
|
||||
.AsNoTracking()
|
||||
.Include(r => r.AgrupacionPolitica)
|
||||
.Where(r => r.EleccionId == eleccionId && r.AmbitoGeografico.NivelId == 30) // Nivel 30 = Municipio
|
||||
.Where(r => r.EleccionId == eleccionId && r.AmbitoGeografico.DistritoId == distritoId && r.AmbitoGeografico.NivelId == 30) // Nivel 30 = Municipio
|
||||
.ToListAsync();
|
||||
|
||||
// Obtenemos TODOS los logos relevantes en una sola consulta
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking()
|
||||
.Where(l => l.EleccionId == eleccionId || l.EleccionId == 0) // Trae los de la elección actual y los de fallback
|
||||
.Where(l => l.EleccionId == eleccionId || l.EleccionId == 0)
|
||||
.ToListAsync();
|
||||
|
||||
// --- LÓGICA DE AGRUPACIÓN Y CÁLCULO ---
|
||||
var resultadosAgrupados = resultadosPorMunicipio
|
||||
.GroupBy(r => r.CategoriaId)
|
||||
.Select(g => new
|
||||
@@ -155,7 +153,6 @@ public class ResultadosController : ControllerBase
|
||||
CategoriaNombre = estadosPorCategoria.ContainsKey(g.Key) ? estadosPorCategoria[g.Key].CategoriaElectoral.Nombre : "Desconocido",
|
||||
EstadoRecuento = estadosPorCategoria.GetValueOrDefault(g.Key),
|
||||
TotalVotosCategoria = g.Sum(r => r.CantidadVotos),
|
||||
// Agrupamos por el ID de la agrupación, no por el objeto, para evitar duplicados
|
||||
ResultadosAgrupados = g.GroupBy(r => r.AgrupacionPoliticaId)
|
||||
.Select(partidoGroup => new
|
||||
{
|
||||
@@ -172,10 +169,7 @@ public class ResultadosController : ControllerBase
|
||||
Resultados = g.ResultadosAgrupados
|
||||
.Select(r =>
|
||||
{
|
||||
// --- USAMOS EL NUEVO MÉTODO HELPER ---
|
||||
// Para el resumen provincial, el ámbito es siempre el de la provincia.
|
||||
var logoUrl = GetLogoUrl(r.Agrupacion.Id, g.CategoriaId, provincia.Id, todosLosLogos);
|
||||
|
||||
return new
|
||||
{
|
||||
Id = r.Agrupacion.Id,
|
||||
@@ -196,7 +190,6 @@ public class ResultadosController : ControllerBase
|
||||
return Ok(resultadosAgrupados);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("bancas-por-seccion/{seccionId}/{camara}")]
|
||||
public async Task<IActionResult> GetBancasPorSeccion([FromRoute] int eleccionId, string seccionId, string camara)
|
||||
{
|
||||
@@ -227,7 +220,7 @@ public class ResultadosController : ControllerBase
|
||||
return NotFound(new { message = $"No se encontró la sección electoral con ID {seccionId}" });
|
||||
}
|
||||
|
||||
// --- CAMBIO 3: Filtrar también por el cargo (cámara) ---
|
||||
// --- Filtrar también por el cargo (cámara) ---
|
||||
var proyecciones = await _dbContext.ProyeccionesBancas
|
||||
.AsNoTracking()
|
||||
.Include(p => p.AgrupacionPolitica)
|
||||
@@ -498,7 +491,7 @@ public class ResultadosController : ControllerBase
|
||||
todasAgrupaciones.TryGetValue(idPartidoPresidenteSenadores ?? "", out var presidenteSenadores);
|
||||
|
||||
string? idPartidoPresidenteDiputados = bancasPorAgrupacion
|
||||
.Where(b => b.CategoriaId == 6)
|
||||
.Where(b => b.CategoriaId == 3)
|
||||
.OrderByDescending(b => b.BancasTotales)
|
||||
.FirstOrDefault()?.AgrupacionId;
|
||||
todasAgrupaciones.TryGetValue(idPartidoPresidenteDiputados ?? "", out var presidenteDiputados);
|
||||
@@ -509,7 +502,7 @@ public class ResultadosController : ControllerBase
|
||||
.Where(b => b.CategoriaId == categoriaId && b.BancasTotales > 0)
|
||||
.Select(b => new { Bancas = b, Agrupacion = todasAgrupaciones[b.AgrupacionId] });
|
||||
|
||||
if (categoriaId == 6) // Diputados
|
||||
if (categoriaId == 3) // Diputados
|
||||
partidosDeCamara = partidosDeCamara.OrderBy(b => b.Agrupacion.OrdenDiputados ?? 999)
|
||||
.ThenByDescending(b => b.Bancas.BancasTotales);
|
||||
else // Senadores
|
||||
@@ -682,7 +675,7 @@ public class ResultadosController : ControllerBase
|
||||
{
|
||||
// Para cada sección, encontramos al partido con más votos.
|
||||
var ganador = g
|
||||
// CAMBIO CLAVE: Agrupamos por el ID de la agrupación, no por el objeto.
|
||||
// Agrupamos por el ID de la agrupación.
|
||||
.GroupBy(r => r.AgrupacionPolitica.Id)
|
||||
.Select(pg => new
|
||||
{
|
||||
@@ -825,8 +818,8 @@ public class ResultadosController : ControllerBase
|
||||
s.Nombre,
|
||||
// Convertimos la lista de IDs de cargo a una lista de strings ("diputados", "senadores")
|
||||
CamarasDisponibles = s.Cargos.Select(CategoriaId =>
|
||||
CategoriaId == 6 ? "diputados" : // Asume 5 = Diputados
|
||||
CategoriaId == 5 ? "senadores" : // Asume 6 = Senadores
|
||||
CategoriaId == 3 ? "diputados" : // Asume 3 = Diputados
|
||||
CategoriaId == 2 ? "senadores" : // Asume 2 = Senadores
|
||||
null
|
||||
).Where(c => c != null).ToList()
|
||||
});
|
||||
@@ -878,7 +871,7 @@ public class ResultadosController : ControllerBase
|
||||
var totalVotosSeccionCategoria = (decimal)resultadosCategoriaSeccion.Sum(r => r.CantidadVotos);
|
||||
|
||||
return resultadosCategoriaSeccion
|
||||
// --- CAMBIO CLAVE: Agrupamos por el ID (string), no por el objeto ---
|
||||
// --- Agrupamos por el ID (string) ---
|
||||
.GroupBy(r => r.AgrupacionPolitica.Id)
|
||||
.Select(g => new
|
||||
{
|
||||
@@ -1011,47 +1004,70 @@ public class ResultadosController : ControllerBase
|
||||
[HttpGet("panel/{ambitoId?}")]
|
||||
public async Task<IActionResult> GetPanelElectoral(int eleccionId, string? ambitoId, [FromQuery] int categoriaId)
|
||||
{
|
||||
// Vista Nacional
|
||||
if (string.IsNullOrEmpty(ambitoId))
|
||||
{
|
||||
// CASO 1: No hay ID -> Vista Nacional
|
||||
return await GetPanelNacional(eleccionId, categoriaId);
|
||||
}
|
||||
|
||||
// CASO 2: El ID es un número (y no un string corto como "02") -> Vista Municipal
|
||||
// La condición clave es que los IDs de distrito son cortos. Los IDs de BD son más largos.
|
||||
// O simplemente, un ID de distrito nunca será un ID de municipio.
|
||||
if (int.TryParse(ambitoId, out int idNumerico) && ambitoId.Length > 2)
|
||||
// Dividimos el ambitoId por el prefijo ":"
|
||||
var parts = ambitoId.Split(new[] { ':' }, 2);
|
||||
|
||||
// Si no tiene el formato esperado, devolvemos un error.
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
return await GetPanelMunicipal(eleccionId, idNumerico, categoriaId);
|
||||
return BadRequest($"El formato del ID de ámbito '{ambitoId}' no es válido. Debe ser 'distrito:ID' o 'municipio:ID'.");
|
||||
}
|
||||
else
|
||||
|
||||
var tipoAmbito = parts[0].ToLower();
|
||||
var id = parts[1];
|
||||
|
||||
switch (tipoAmbito)
|
||||
{
|
||||
// CASO 3: El ID es un string corto como "02" o "06" -> Vista Provincial
|
||||
return await GetPanelProvincial(eleccionId, ambitoId, categoriaId);
|
||||
case "distrito":
|
||||
// Es una provincia. Llamamos al método provincial con el ID.
|
||||
var esProvinciaValida = await _dbContext.AmbitosGeograficos.AnyAsync(a => a.NivelId == 10 && a.DistritoId == id);
|
||||
if (!esProvinciaValida)
|
||||
{
|
||||
return NotFound($"No se encontró una provincia con distritoId '{id}'.");
|
||||
}
|
||||
return await GetPanelProvincial(eleccionId, id, categoriaId);
|
||||
|
||||
case "municipio":
|
||||
// Es un municipio. Intentamos convertir el ID a número.
|
||||
if (int.TryParse(id, out int idNumerico))
|
||||
{
|
||||
var esMunicipioValido = await _dbContext.AmbitosGeograficos.AnyAsync(a => a.Id == idNumerico && a.NivelId == 30);
|
||||
if (!esMunicipioValido)
|
||||
{
|
||||
return NotFound($"No se encontró un municipio con Id '{idNumerico}'.");
|
||||
}
|
||||
return await GetPanelMunicipal(eleccionId, idNumerico, categoriaId);
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest($"El ID de municipio '{id}' no es un número válido.");
|
||||
}
|
||||
|
||||
default:
|
||||
// Si el prefijo no es ni "distrito" ni "municipio", es un error.
|
||||
return BadRequest($"Tipo de ámbito desconocido: '{tipoAmbito}'. Use 'distrito' o 'municipio'.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetPanelMunicipal(int eleccionId, int ambitoId, int categoriaId)
|
||||
{
|
||||
// 1. Obtener la entidad del municipio y, a partir de ella, la de su provincia.
|
||||
var municipio = await _dbContext.AmbitosGeograficos.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == ambitoId && a.NivelId == 30);
|
||||
|
||||
if (municipio == null) return NotFound($"No se encontró el municipio con ID {ambitoId}.");
|
||||
|
||||
var provincia = await _dbContext.AmbitosGeograficos.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.DistritoId == municipio.DistritoId && a.NivelId == 10);
|
||||
|
||||
// Si por alguna razón no encontramos la provincia, no podemos continuar.
|
||||
if (provincia == null) return NotFound($"No se pudo determinar la provincia para el municipio con ID {ambitoId}.");
|
||||
|
||||
// 2. Cargar todos los overrides de candidatos y logos relevantes (igual que en la vista provincial).
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking()
|
||||
.Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking()
|
||||
.Where(l => l.EleccionId == eleccionId || l.EleccionId == 0).ToListAsync();
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking().Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking().Where(l => l.EleccionId == eleccionId || l.EleccionId == 0).ToListAsync();
|
||||
|
||||
// 3. Obtener los votos solo para ESE municipio (esto no cambia).
|
||||
var resultadosCrudos = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
.Include(r => r.AgrupacionPolitica)
|
||||
.Where(r => r.EleccionId == eleccionId &&
|
||||
@@ -1059,26 +1075,17 @@ public class ResultadosController : ControllerBase
|
||||
r.AmbitoGeograficoId == ambitoId)
|
||||
.ToListAsync();
|
||||
|
||||
// El resto de la lógica es muy similar, pero ahora usamos los helpers con el ID de la provincia.
|
||||
if (!resultadosCrudos.Any())
|
||||
{
|
||||
return Ok(new PanelElectoralDto
|
||||
{
|
||||
AmbitoNombre = municipio.Nombre,
|
||||
MapaData = new List<ResultadoMapaDto>(),
|
||||
ResultadosPanel = new List<AgrupacionResultadoDto>(),
|
||||
EstadoRecuento = new EstadoRecuentoDto()
|
||||
});
|
||||
return Ok(new PanelElectoralDto { AmbitoNombre = municipio.Nombre, MapaData = new List<ResultadoMapaDto>(), ResultadosPanel = new List<AgrupacionResultadoDto>(), EstadoRecuento = new EstadoRecuentoDto() });
|
||||
}
|
||||
|
||||
var totalVotosMunicipio = (decimal)resultadosCrudos.Sum(r => r.CantidadVotos);
|
||||
var resultadosPanel = resultadosCrudos
|
||||
.Select(g =>
|
||||
{
|
||||
// 4. ¡LA CLAVE! Usamos el ID de la PROVINCIA para buscar el override.
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, g.AgrupacionPolitica.Id, categoriaId, provincia.Id, eleccionId);
|
||||
var logoMatch = FindBestLogoMatch(todosLosLogos, g.AgrupacionPolitica.Id, categoriaId, provincia.Id, eleccionId);
|
||||
|
||||
return new AgrupacionResultadoDto
|
||||
{
|
||||
Id = g.AgrupacionPolitica.Id,
|
||||
@@ -1087,7 +1094,6 @@ public class ResultadosController : ControllerBase
|
||||
Color = g.AgrupacionPolitica.Color,
|
||||
Votos = g.CantidadVotos,
|
||||
Porcentaje = totalVotosMunicipio > 0 ? (g.CantidadVotos / totalVotosMunicipio) * 100 : 0,
|
||||
// Asignamos los datos del override encontrado
|
||||
NombreCandidato = candidatoMatch?.NombreCandidato,
|
||||
LogoUrl = logoMatch?.LogoUrl
|
||||
};
|
||||
@@ -1114,44 +1120,50 @@ public class ResultadosController : ControllerBase
|
||||
return Ok(respuesta);
|
||||
}
|
||||
|
||||
// Este método se ejecutará cuando la URL sea, por ejemplo, /api/elecciones/2/panel/02?categoriaId=2
|
||||
private async Task<IActionResult> GetPanelProvincial(int eleccionId, string distritoId, int categoriaId)
|
||||
{
|
||||
var provincia = await _dbContext.AmbitosGeograficos.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.DistritoId == distritoId && a.NivelId == 10);
|
||||
if (provincia == null) return NotFound($"No se encontró la provincia con DistritoId {distritoId}.");
|
||||
|
||||
// --- INICIO DE LA MODIFICACIÓN ---
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking().Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking().Where(l => l.EleccionId == eleccionId || l.EleccionId == 0).ToListAsync();
|
||||
// --- FIN DE LA MODIFICACIÓN ---
|
||||
|
||||
// ... (la lógica de agregación de votos no cambia)
|
||||
var resultadosAgregados = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
.Where(r => r.EleccionId == eleccionId && r.CategoriaId == categoriaId && r.AmbitoGeografico.DistritoId == distritoId && r.AmbitoGeografico.NivelId == 30)
|
||||
.GroupBy(r => r.AgrupacionPolitica)
|
||||
.Select(g => new { Agrupacion = g.Key, TotalVotos = g.Sum(r => r.CantidadVotos) })
|
||||
var votosAgregados = await _dbContext.ResultadosVotos
|
||||
.AsNoTracking()
|
||||
.Where(r => r.EleccionId == eleccionId
|
||||
&& r.CategoriaId == categoriaId
|
||||
&& r.AmbitoGeografico.DistritoId == distritoId
|
||||
&& r.AmbitoGeografico.NivelId == 30)
|
||||
.GroupBy(r => new { r.AgrupacionPoliticaId, r.AgrupacionPolitica.Nombre, r.AgrupacionPolitica.NombreCorto, r.AgrupacionPolitica.Color })
|
||||
.Select(g => new
|
||||
{
|
||||
AgrupacionId = g.Key.AgrupacionPoliticaId,
|
||||
Nombre = g.Key.Nombre,
|
||||
NombreCorto = g.Key.NombreCorto,
|
||||
Color = g.Key.Color,
|
||||
TotalVotos = g.Sum(r => r.CantidadVotos)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var totalVotosProvincia = (decimal)resultadosAgregados.Sum(r => r.TotalVotos);
|
||||
var totalVotosProvincia = (decimal)votosAgregados.Sum(r => r.TotalVotos);
|
||||
|
||||
var resultadosPanel = resultadosAgregados
|
||||
var resultadosPanel = votosAgregados
|
||||
.Select(g =>
|
||||
{
|
||||
// Aplicamos la misma lógica de búsqueda de overrides
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, g.Agrupacion.Id, categoriaId, provincia.Id, eleccionId);
|
||||
var logoMatch = FindBestLogoMatch(todosLosLogos, g.Agrupacion.Id, categoriaId, provincia.Id, eleccionId);
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, g.AgrupacionId, categoriaId, provincia.Id, eleccionId);
|
||||
var logoMatch = FindBestLogoMatch(todosLosLogos, g.AgrupacionId, categoriaId, provincia.Id, eleccionId);
|
||||
|
||||
return new AgrupacionResultadoDto
|
||||
{
|
||||
Id = g.Agrupacion.Id,
|
||||
Nombre = g.Agrupacion.Nombre,
|
||||
NombreCorto = g.Agrupacion.NombreCorto,
|
||||
Color = g.Agrupacion.Color,
|
||||
Id = g.AgrupacionId,
|
||||
Nombre = g.Nombre,
|
||||
NombreCorto = g.NombreCorto,
|
||||
Color = g.Color,
|
||||
Votos = g.TotalVotos,
|
||||
Porcentaje = totalVotosProvincia > 0 ? (g.TotalVotos / totalVotosProvincia) * 100 : 0,
|
||||
NombreCandidato = candidatoMatch?.NombreCandidato, // <-- DATO AÑADIDO
|
||||
LogoUrl = logoMatch?.LogoUrl // <-- DATO AÑADIDO
|
||||
NombreCandidato = candidatoMatch?.NombreCandidato,
|
||||
LogoUrl = logoMatch?.LogoUrl
|
||||
};
|
||||
})
|
||||
.OrderByDescending(r => r.Votos)
|
||||
@@ -1163,7 +1175,7 @@ public class ResultadosController : ControllerBase
|
||||
var respuesta = new PanelElectoralDto
|
||||
{
|
||||
AmbitoNombre = provincia.Nombre,
|
||||
MapaData = new List<ResultadoMapaDto>(), // Se carga por separado
|
||||
MapaData = new List<ResultadoMapaDto>(),
|
||||
ResultadosPanel = resultadosPanel,
|
||||
EstadoRecuento = new EstadoRecuentoDto
|
||||
{
|
||||
@@ -1176,41 +1188,124 @@ public class ResultadosController : ControllerBase
|
||||
|
||||
private async Task<IActionResult> GetPanelNacional(int eleccionId, int categoriaId)
|
||||
{
|
||||
// 1. Obtenemos todos los datos necesarios al inicio
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking().Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking().Where(l => l.EleccionId == eleccionId || l.EleccionId == 0).ToListAsync();
|
||||
var mapeoNacional = await _dbContext.MapeoAgrupaciones.ToDictionaryAsync(m => m.IdAgrupacionProvincial);
|
||||
|
||||
|
||||
var resultadosAgregados = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
var votosProvinciales = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
.Include(r => r.AgrupacionPolitica)
|
||||
.Where(r => r.EleccionId == eleccionId && r.CategoriaId == categoriaId)
|
||||
.GroupBy(r => r.AgrupacionPolitica)
|
||||
.Select(g => new { Agrupacion = g.Key, TotalVotos = g.Sum(r => r.CantidadVotos) })
|
||||
.ToListAsync();
|
||||
|
||||
// 2. Agrupamos los votos usando la tabla de mapeo
|
||||
var resultadosAgregados = votosProvinciales
|
||||
.Select(voto =>
|
||||
{
|
||||
if (mapeoNacional.TryGetValue(voto.AgrupacionPoliticaId, out var mapping))
|
||||
{
|
||||
return new
|
||||
{
|
||||
Id = mapping.AgrupacionNacional,
|
||||
Nombre = mapping.AgrupacionNacional,
|
||||
NombreCorto = mapping.NombreCortoNacional,
|
||||
Color = mapping.ColorNacional,
|
||||
LogoUrl = mapping.LogoUrlNacional,
|
||||
OriginalId = voto.AgrupacionPoliticaId,
|
||||
CantidadVotos = voto.CantidadVotos
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new
|
||||
{
|
||||
Id = voto.AgrupacionPolitica.Id,
|
||||
Nombre = voto.AgrupacionPolitica.Nombre,
|
||||
NombreCorto = voto.AgrupacionPolitica.NombreCorto,
|
||||
Color = voto.AgrupacionPolitica.Color,
|
||||
LogoUrl = (string?)null,
|
||||
OriginalId = voto.AgrupacionPolitica.Id,
|
||||
CantidadVotos = voto.CantidadVotos
|
||||
};
|
||||
}
|
||||
})
|
||||
.GroupBy(x => x.Id)
|
||||
.Select(g =>
|
||||
{
|
||||
var first = g.First();
|
||||
return new
|
||||
{
|
||||
Agrupacion = new
|
||||
{
|
||||
Id = first.Id,
|
||||
Nombre = first.Nombre,
|
||||
NombreCorto = first.NombreCorto,
|
||||
Color = first.Color,
|
||||
LogoUrl = first.LogoUrl,
|
||||
ProvincialIdFallback = first.OriginalId
|
||||
},
|
||||
TotalVotos = g.Sum(item => item.CantidadVotos)
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var totalVotosNacional = (decimal)resultadosAgregados.Sum(r => r.TotalVotos);
|
||||
|
||||
// 3. Construimos los resultados del panel con la lógica de fallback para el logo
|
||||
var resultadosPanel = resultadosAgregados
|
||||
.Select(g =>
|
||||
{
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, g.Agrupacion.Id, categoriaId, null, eleccionId);
|
||||
var logoMatch = FindBestLogoMatch(todosLosLogos, g.Agrupacion.Id, categoriaId, null, eleccionId);
|
||||
|
||||
return new AgrupacionResultadoDto
|
||||
.Select(g =>
|
||||
{
|
||||
Id = g.Agrupacion.Id,
|
||||
Nombre = g.Agrupacion.Nombre,
|
||||
NombreCorto = g.Agrupacion.NombreCorto,
|
||||
Color = g.Agrupacion.Color,
|
||||
Votos = g.TotalVotos,
|
||||
Porcentaje = totalVotosNacional > 0 ? (g.TotalVotos / totalVotosNacional) * 100 : 0,
|
||||
NombreCandidato = null,
|
||||
LogoUrl = logoMatch?.LogoUrl
|
||||
};
|
||||
})
|
||||
.OrderByDescending(r => r.Votos)
|
||||
.ToList();
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, g.Agrupacion.Id, categoriaId, null, eleccionId);
|
||||
var logoFinal = g.Agrupacion.LogoUrl ?? FindBestLogoMatch(
|
||||
todosLosLogos,
|
||||
g.Agrupacion.ProvincialIdFallback,
|
||||
categoriaId,
|
||||
null,
|
||||
eleccionId)?.LogoUrl;
|
||||
|
||||
var estadoRecuento = await _dbContext.EstadosRecuentosGenerales.AsNoTracking()
|
||||
.FirstOrDefaultAsync(e => e.EleccionId == eleccionId && e.CategoriaId == categoriaId && e.AmbitoGeografico.NivelId == 0);
|
||||
return new AgrupacionResultadoDto
|
||||
{
|
||||
Id = g.Agrupacion.Id,
|
||||
Nombre = g.Agrupacion.Nombre,
|
||||
NombreCorto = g.Agrupacion.NombreCorto,
|
||||
Color = g.Agrupacion.Color,
|
||||
Votos = g.TotalVotos,
|
||||
Porcentaje = totalVotosNacional > 0 ? (g.TotalVotos / totalVotosNacional) * 100 : 0,
|
||||
NombreCandidato = candidatoMatch?.NombreCandidato,
|
||||
LogoUrl = logoFinal
|
||||
};
|
||||
})
|
||||
.OrderByDescending(r => r.Votos)
|
||||
.ToList();
|
||||
|
||||
// El resto del método para calcular los totales de escrutinio permanece igual.
|
||||
var totalesProvincialesAgregados = await _dbContext.EstadosRecuentosGenerales
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EleccionId == eleccionId && e.CategoriaId == categoriaId && e.AmbitoGeografico.NivelId == 10)
|
||||
.GroupBy(e => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalVotantes = g.Sum(e => e.CantidadVotantes),
|
||||
TotalElectores = g.Sum(e => e.CantidadElectores),
|
||||
TotalMesasTotalizadas = g.Sum(e => e.MesasTotalizadas),
|
||||
TotalMesasEsperadas = g.Sum(e => e.MesasEsperadas)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
decimal participacionNacional = 0;
|
||||
decimal escrutadoNacional = 0;
|
||||
|
||||
if (totalesProvincialesAgregados != null)
|
||||
{
|
||||
if (totalesProvincialesAgregados.TotalElectores > 0)
|
||||
{
|
||||
participacionNacional = (decimal)totalesProvincialesAgregados.TotalVotantes * 100 / totalesProvincialesAgregados.TotalElectores;
|
||||
}
|
||||
if (totalesProvincialesAgregados.TotalMesasEsperadas > 0)
|
||||
{
|
||||
escrutadoNacional = (decimal)totalesProvincialesAgregados.TotalMesasTotalizadas * 100 / totalesProvincialesAgregados.TotalMesasEsperadas;
|
||||
}
|
||||
}
|
||||
|
||||
var respuesta = new PanelElectoralDto
|
||||
{
|
||||
@@ -1219,8 +1314,8 @@ public class ResultadosController : ControllerBase
|
||||
ResultadosPanel = resultadosPanel,
|
||||
EstadoRecuento = new EstadoRecuentoDto
|
||||
{
|
||||
ParticipacionPorcentaje = estadoRecuento?.ParticipacionPorcentaje ?? 0,
|
||||
MesasTotalizadasPorcentaje = estadoRecuento?.MesasTotalizadasPorcentaje ?? 0
|
||||
ParticipacionPorcentaje = participacionNacional,
|
||||
MesasTotalizadasPorcentaje = escrutadoNacional
|
||||
}
|
||||
};
|
||||
return Ok(respuesta);
|
||||
@@ -1348,9 +1443,9 @@ public class ResultadosController : ControllerBase
|
||||
{
|
||||
Agrupacion = agrupacion,
|
||||
DiputadosFijos = bancasPrevias.FirstOrDefault(b => b.AgrupacionPoliticaId == agrupacion.Id && b.Camara == Core.Enums.TipoCamara.Diputados)?.Cantidad ?? 0,
|
||||
DiputadosGanados = proyecciones.Where(p => p.AgrupacionPoliticaId == agrupacion.Id && p.CategoriaId == 2).Sum(p => p.NroBancas),
|
||||
DiputadosGanados = proyecciones.Where(p => p.AgrupacionPoliticaId == agrupacion.Id && p.CategoriaId == 3).Sum(p => p.NroBancas),
|
||||
SenadoresFijos = bancasPrevias.FirstOrDefault(b => b.AgrupacionPoliticaId == agrupacion.Id && b.Camara == Core.Enums.TipoCamara.Senadores)?.Cantidad ?? 0,
|
||||
SenadoresGanados = proyecciones.Where(p => p.AgrupacionPoliticaId == agrupacion.Id && p.CategoriaId == 1).Sum(p => p.NroBancas)
|
||||
SenadoresGanados = proyecciones.Where(p => p.AgrupacionPoliticaId == agrupacion.Id && p.CategoriaId == 2).Sum(p => p.NroBancas)
|
||||
})
|
||||
.Select(r => new
|
||||
{
|
||||
@@ -1442,18 +1537,18 @@ List<CandidatoOverride> overrides, string agrupacionId, int categoriaId, int? am
|
||||
}
|
||||
private LogoAgrupacionCategoria? FindBestLogoMatch(
|
||||
List<LogoAgrupacionCategoria> logos, string agrupacionId, int categoriaId, int? ambitoId, int eleccionId)
|
||||
{
|
||||
// Prioridad 1: Coincidencia exacta (Elección, Categoría, Ámbito)
|
||||
return logos.FirstOrDefault(l => l.EleccionId == eleccionId && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == ambitoId)
|
||||
// Prioridad 2: Coincidencia por Elección y Categoría (Ámbito genérico)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == eleccionId && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == null)
|
||||
// Prioridad 3: Coincidencia de Fallback por Ámbito (Elección genérica)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == ambitoId)
|
||||
// Prioridad 4: Coincidencia de Fallback por Categoría (Elección y Ámbito genéricos)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == null)
|
||||
// Prioridad 5: LOGO GLOBAL. Coincidencia solo por Partido (Elección y Categoría genéricas)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == 0 && l.AmbitoGeograficoId == null);
|
||||
}
|
||||
{
|
||||
// Prioridad 1: Coincidencia exacta (Elección, Categoría, Ámbito)
|
||||
return logos.FirstOrDefault(l => l.EleccionId == eleccionId && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == ambitoId)
|
||||
// Prioridad 2: Coincidencia por Elección y Categoría (Ámbito genérico)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == eleccionId && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == null)
|
||||
// Prioridad 3: Coincidencia de Fallback por Ámbito (Elección genérica)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == ambitoId)
|
||||
// Prioridad 4: Coincidencia de Fallback por Categoría (Elección y Ámbito genéricos)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == categoriaId && l.AmbitoGeograficoId == null)
|
||||
// Prioridad 5: LOGO GLOBAL. Coincidencia solo por Partido (Elección y Categoría genéricas)
|
||||
?? logos.FirstOrDefault(l => l.EleccionId == 0 && l.AgrupacionPoliticaId == agrupacionId && l.CategoriaId == 0 && l.AmbitoGeograficoId == null);
|
||||
}
|
||||
|
||||
[HttpGet("resumen-por-provincia")]
|
||||
public async Task<IActionResult> GetResumenPorProvincia(
|
||||
@@ -1464,10 +1559,10 @@ List<CandidatoOverride> overrides, string agrupacionId, int categoriaId, int? am
|
||||
{
|
||||
if (cantidadResultados < 1) cantidadResultados = 1;
|
||||
|
||||
const int catDiputadosNac = 2;
|
||||
const int catSenadoresNac = 1;
|
||||
const int catDiputadosNac = 3;
|
||||
const int catSenadoresNac = 2;
|
||||
|
||||
var provinciasQueRenuevanSenadores = new HashSet<string> { "01", "06", "08", "15", "16", "17", "22", "23" };
|
||||
var provinciasQueRenuevanSenadores = new HashSet<string> { "01", "06", "08", "15", "16", "17", "22", "24" };
|
||||
var todasLasProyecciones = await _dbContext.ProyeccionesBancas.AsNoTracking().Where(p => p.EleccionId == eleccionId && (p.CategoriaId == catDiputadosNac || p.CategoriaId == catSenadoresNac)).ToDictionaryAsync(p => p.AmbitoGeograficoId + "_" + p.AgrupacionPoliticaId + "_" + p.CategoriaId);
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking().Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking().Where(l => l.EleccionId == eleccionId || l.EleccionId == 0).ToListAsync();
|
||||
@@ -1480,6 +1575,10 @@ List<CandidatoOverride> overrides, string agrupacionId, int categoriaId, int? am
|
||||
var resultadosFinales = new List<ResumenProvinciaDto>();
|
||||
var provinciasQuery = _dbContext.AmbitosGeograficos.AsNoTracking().Where(a => a.NivelId == 10);
|
||||
if (!string.IsNullOrEmpty(focoDistritoId)) { provinciasQuery = provinciasQuery.Where(p => p.DistritoId == focoDistritoId); }
|
||||
if (focoCategoriaId.HasValue && focoCategoriaId.Value == catSenadoresNac)
|
||||
{
|
||||
provinciasQuery = provinciasQuery.Where(p => p.DistritoId != null && provinciasQueRenuevanSenadores.Contains(p.DistritoId));
|
||||
}
|
||||
var provincias = await provinciasQuery.ToListAsync();
|
||||
if (!provincias.Any()) { return Ok(resultadosFinales); }
|
||||
|
||||
@@ -1575,7 +1674,7 @@ List<CandidatoOverride> overrides, string agrupacionId, int categoriaId, int? am
|
||||
var respuesta = new CategoriaResumenHomeDto
|
||||
{
|
||||
CategoriaId = categoriaId,
|
||||
CategoriaNombre = estado?.CategoriaElectoral.Nombre ?? (categoriaId == 2 ? "DIPUTADOS NACIONALES" : "SENADORES NACIONALES"),
|
||||
CategoriaNombre = estado?.CategoriaElectoral.Nombre ?? (categoriaId == 3 ? "DIPUTADOS NACIONALES" : "SENADORES NACIONALES"),
|
||||
UltimaActualizacion = estado?.FechaTotalizacion ?? DateTime.UtcNow,
|
||||
EstadoRecuento = estado != null ? new EstadoRecuentoDto
|
||||
{
|
||||
@@ -1617,4 +1716,358 @@ List<CandidatoOverride> overrides, string agrupacionId, int categoriaId, int? am
|
||||
|
||||
return Ok(respuesta);
|
||||
}
|
||||
|
||||
[HttpGet("~/api/elecciones/home-resumen-nacional")]
|
||||
public async Task<IActionResult> GetHomeResumenNacional(
|
||||
[FromQuery] int eleccionId,
|
||||
[FromQuery] int categoriaId)
|
||||
{
|
||||
// 1. OBTENER TODOS LOS DATOS NECESARIOS AL INICIO
|
||||
var votosProvinciales = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
.Include(r => r.AgrupacionPolitica)
|
||||
.Where(r => r.EleccionId == eleccionId && r.CategoriaId == categoriaId)
|
||||
.ToListAsync();
|
||||
|
||||
var mapeoNacional = await _dbContext.MapeoAgrupaciones
|
||||
.ToDictionaryAsync(m => m.IdAgrupacionProvincial);
|
||||
|
||||
// --- INICIO DE LA MODIFICACIÓN ---
|
||||
// Añadimos la obtención de todos los logos de overrides
|
||||
var todosLosLogos = await _dbContext.LogosAgrupacionesCategorias.AsNoTracking()
|
||||
.Where(l => l.EleccionId == eleccionId || l.EleccionId == 0)
|
||||
.ToListAsync();
|
||||
// --- FIN DE LA MODIFICACIÓN ---
|
||||
|
||||
var votosAgregados = votosProvinciales
|
||||
.GroupBy(voto =>
|
||||
{
|
||||
if (mapeoNacional.TryGetValue(voto.AgrupacionPoliticaId, out var mapping))
|
||||
{
|
||||
// Es un partido mapeado, usamos datos nacionales
|
||||
return new
|
||||
{
|
||||
Id = mapping.AgrupacionNacional,
|
||||
Nombre = mapping.AgrupacionNacional,
|
||||
NombreCorto = mapping.NombreCortoNacional,
|
||||
Color = mapping.ColorNacional,
|
||||
LogoUrl = mapping.LogoUrlNacional,
|
||||
// Guardamos el ID original para poder buscar overrides si es necesario
|
||||
OriginalId = voto.AgrupacionPoliticaId
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Es un partido no mapeado, usamos sus propios datos
|
||||
return new
|
||||
{
|
||||
Id = voto.AgrupacionPolitica.Id,
|
||||
Nombre = voto.AgrupacionPolitica.Nombre,
|
||||
NombreCorto = voto.AgrupacionPolitica.NombreCorto,
|
||||
Color = voto.AgrupacionPolitica.Color,
|
||||
LogoUrl = (string?)null, // Lo buscaremos después
|
||||
OriginalId = voto.AgrupacionPolitica.Id
|
||||
};
|
||||
}
|
||||
})
|
||||
// Agrupamos por el Id del Key del grupo anónimo (Id nacional)
|
||||
.GroupBy(g => g.Key.Id)
|
||||
.Select(finalGroup =>
|
||||
{
|
||||
// Cada elemento de finalGroup es un IGrouping<anonKey, ResultadoVoto>
|
||||
// Tomamos el primer grupo y su Key para obtener los metadatos de la agrupación
|
||||
var firstKey = finalGroup.First().Key;
|
||||
return new
|
||||
{
|
||||
Agrupacion = new
|
||||
{
|
||||
Id = firstKey.Id,
|
||||
Nombre = firstKey.Nombre,
|
||||
NombreCorto = firstKey.NombreCorto,
|
||||
Color = firstKey.Color,
|
||||
LogoUrl = firstKey.LogoUrl,
|
||||
// Importante: Tomamos un ID provincial representativo para el fallback
|
||||
ProvincialIdFallback = firstKey.OriginalId
|
||||
},
|
||||
// Sumamos todas las cantidades dentro de los grupos internos
|
||||
Votos = finalGroup.Sum(innerGroup => innerGroup.Sum(v => v.CantidadVotos))
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Votos)
|
||||
.ToList();
|
||||
|
||||
// El resto de la lógica para calcular totales y porcentajes permanece igual.
|
||||
var todosLosOverrides = await _dbContext.CandidatosOverrides.AsNoTracking().Where(c => c.EleccionId == eleccionId || c.EleccionId == 0).ToListAsync();
|
||||
var categoriaInfo = await _dbContext.CategoriasElectorales.AsNoTracking().FirstOrDefaultAsync(c => c.Id == categoriaId);
|
||||
|
||||
var totalesProvincialesAgregados = await _dbContext.EstadosRecuentosGenerales
|
||||
.AsNoTracking()
|
||||
.Where(e => e.EleccionId == eleccionId && e.CategoriaId == categoriaId && e.AmbitoGeografico.NivelId == 10)
|
||||
.GroupBy(e => 1)
|
||||
.Select(g => new
|
||||
{
|
||||
TotalVotantes = g.Sum(e => e.CantidadVotantes),
|
||||
TotalElectores = g.Sum(e => e.CantidadElectores),
|
||||
TotalMesasTotalizadas = g.Sum(e => e.MesasTotalizadas),
|
||||
TotalMesasEsperadas = g.Sum(e => e.MesasEsperadas),
|
||||
UltimaFecha = g.Max(e => e.FechaTotalizacion)
|
||||
})
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var estadoRecuentoDto = new EstadoRecuentoDto();
|
||||
DateTime ultimaActualizacion = DateTime.UtcNow;
|
||||
|
||||
if (totalesProvincialesAgregados != null)
|
||||
{
|
||||
estadoRecuentoDto.CantidadVotantes = totalesProvincialesAgregados.TotalVotantes;
|
||||
ultimaActualizacion = totalesProvincialesAgregados.UltimaFecha;
|
||||
if (totalesProvincialesAgregados.TotalElectores > 0)
|
||||
{
|
||||
estadoRecuentoDto.ParticipacionPorcentaje = (decimal)totalesProvincialesAgregados.TotalVotantes * 100 / totalesProvincialesAgregados.TotalElectores;
|
||||
}
|
||||
if (totalesProvincialesAgregados.TotalMesasEsperadas > 0)
|
||||
{
|
||||
estadoRecuentoDto.MesasTotalizadasPorcentaje = (decimal)totalesProvincialesAgregados.TotalMesasTotalizadas * 100 / totalesProvincialesAgregados.TotalMesasEsperadas;
|
||||
}
|
||||
}
|
||||
|
||||
var votosNoPositivosAgregados = await _dbContext.EstadosRecuentos.AsNoTracking()
|
||||
.Where(e => e.EleccionId == eleccionId && e.CategoriaId == categoriaId)
|
||||
.GroupBy(e => 1)
|
||||
.Select(g => new { VotosEnBlanco = g.Sum(e => e.VotosEnBlanco), VotosNulos = g.Sum(e => e.VotosNulos), VotosRecurridos = g.Sum(e => e.VotosRecurridos) })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var totalVotosPositivos = (decimal)votosAgregados.Sum(r => r.Votos);
|
||||
var votosEnBlanco = votosNoPositivosAgregados?.VotosEnBlanco ?? 0;
|
||||
var votosTotales = totalVotosPositivos + votosEnBlanco + (votosNoPositivosAgregados?.VotosNulos ?? 0) + (votosNoPositivosAgregados?.VotosRecurridos ?? 0);
|
||||
|
||||
var respuesta = new CategoriaResumenHomeDto
|
||||
{
|
||||
CategoriaId = categoriaId,
|
||||
CategoriaNombre = categoriaInfo?.Nombre ?? (categoriaId == 3 ? "DIPUTADOS NACIONALES" : "SENADORES NACIONALES"),
|
||||
UltimaActualizacion = ultimaActualizacion,
|
||||
EstadoRecuento = estadoRecuentoDto,
|
||||
VotosEnBlanco = votosEnBlanco,
|
||||
VotosEnBlancoPorcentaje = votosTotales > 0 ? (votosEnBlanco / votosTotales) * 100 : 0,
|
||||
VotosTotales = (long)votosTotales,
|
||||
Resultados = votosAgregados.Select(r =>
|
||||
{
|
||||
var candidatoMatch = FindBestCandidatoMatch(todosLosOverrides, r.Agrupacion.Id, categoriaId, null, eleccionId);
|
||||
|
||||
// --- INICIO DE LA MODIFICACIÓN ---
|
||||
// Lógica de fallback para el logo
|
||||
var logoFinal = r.Agrupacion.LogoUrl ?? FindBestLogoMatch(
|
||||
todosLosLogos,
|
||||
r.Agrupacion.ProvincialIdFallback, // Usamos el ID provincial para la búsqueda de fallback
|
||||
categoriaId,
|
||||
null,
|
||||
eleccionId)?.LogoUrl;
|
||||
// --- FIN DE LA MODIFICACIÓN ---
|
||||
|
||||
return new ResultadoCandidatoDto
|
||||
{
|
||||
AgrupacionId = r.Agrupacion.Id,
|
||||
NombreAgrupacion = r.Agrupacion.Nombre,
|
||||
NombreCortoAgrupacion = r.Agrupacion.NombreCorto,
|
||||
NombreCandidato = candidatoMatch?.NombreCandidato,
|
||||
Color = r.Agrupacion.Color,
|
||||
Votos = r.Votos,
|
||||
Porcentaje = totalVotosPositivos > 0 ? (r.Votos / totalVotosPositivos) * 100 : 0,
|
||||
FotoUrl = logoFinal // Usamos el logo obtenido con la lógica de fallback
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return Ok(respuesta);
|
||||
}
|
||||
|
||||
[HttpGet("tabla-conurbano")]
|
||||
public async Task<IActionResult> GetTablaConurbano([FromRoute] int eleccionId)
|
||||
{
|
||||
const int categoriaId = 3; // Diputados Nacionales
|
||||
|
||||
var municipiosConurbano = await (
|
||||
from conurbano in _dbContext.Conurbano
|
||||
join ambito in _dbContext.AmbitosGeograficos on conurbano.AmbitoGeograficoId equals ambito.Id
|
||||
orderby conurbano.Orden
|
||||
select new
|
||||
{
|
||||
conurbano.AmbitoGeograficoId,
|
||||
conurbano.Orden,
|
||||
ambito.Nombre
|
||||
})
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
var idsMunicipios = municipiosConurbano.Select(m => m.AmbitoGeograficoId).ToList();
|
||||
|
||||
var todosLosVotos = await _dbContext.ResultadosVotos
|
||||
.AsNoTracking()
|
||||
.Include(rv => rv.AgrupacionPolitica)
|
||||
.Where(rv => rv.EleccionId == eleccionId && rv.CategoriaId == categoriaId && idsMunicipios.Contains(rv.AmbitoGeograficoId))
|
||||
.ToListAsync();
|
||||
|
||||
var resultadosFinales = new List<Elecciones.Core.DTOs.ApiResponses.Tablas.ResultadoFilaDto>();
|
||||
|
||||
foreach (var municipio in municipiosConurbano)
|
||||
{
|
||||
var votosMunicipio = todosLosVotos.Where(v => v.AmbitoGeograficoId == municipio.AmbitoGeograficoId).ToList();
|
||||
var totalVotos = (decimal)votosMunicipio.Sum(v => v.CantidadVotos);
|
||||
var top2 = votosMunicipio.OrderByDescending(v => v.CantidadVotos).Take(2).ToList();
|
||||
|
||||
string GetDisplayName(ResultadoVoto? voto)
|
||||
{
|
||||
if (voto == null) return "N/D";
|
||||
return voto.AgrupacionPolitica.NombreCorto ?? voto.AgrupacionPolitica.Nombre;
|
||||
}
|
||||
|
||||
var fila = new Elecciones.Core.DTOs.ApiResponses.Tablas.ResultadoFilaDto
|
||||
{
|
||||
AmbitoId = municipio.AmbitoGeograficoId,
|
||||
Nombre = municipio.Nombre,
|
||||
Orden = municipio.Orden,
|
||||
Fuerza1Display = GetDisplayName(top2.FirstOrDefault()),
|
||||
Fuerza1Porcentaje = top2.Count > 0 && totalVotos > 0 ? (top2[0].CantidadVotos / totalVotos) * 100 : 0,
|
||||
Fuerza2Display = GetDisplayName(top2.Skip(1).FirstOrDefault()),
|
||||
Fuerza2Porcentaje = top2.Count > 1 && totalVotos > 0 ? (top2[1].CantidadVotos / totalVotos) * 100 : 0,
|
||||
};
|
||||
resultadosFinales.Add(fila);
|
||||
}
|
||||
|
||||
return Ok(resultadosFinales);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("tabla-secciones")]
|
||||
public async Task<IActionResult> GetTablaSecciones([FromRoute] int eleccionId)
|
||||
{
|
||||
const int categoriaId = 3;
|
||||
|
||||
var provinciaBA = await _dbContext.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.DistritoId == "02" && a.NivelId == 10);
|
||||
if (provinciaBA == null) return NotFound("No se encontró el ámbito de la Provincia de Buenos Aires.");
|
||||
|
||||
var secciones = await _dbContext.AmbitosGeograficos
|
||||
.AsNoTracking()
|
||||
.Where(a => a.NivelId == 20 && a.DistritoId == "02" && a.SeccionProvincialId != null)
|
||||
.OrderBy(a => a.SeccionProvincialId)
|
||||
.ToListAsync();
|
||||
|
||||
var idsSecciones = secciones.Select(s => s.SeccionProvincialId).ToList();
|
||||
var municipiosDeSecciones = await _dbContext.AmbitosGeograficos.AsNoTracking()
|
||||
.Where(a => a.NivelId == 30 && a.DistritoId == "02" && idsSecciones.Contains(a.SeccionProvincialId)).ToListAsync();
|
||||
var idsMunicipios = municipiosDeSecciones.Select(m => m.Id).ToList();
|
||||
|
||||
var todosLosVotos = await _dbContext.ResultadosVotos.AsNoTracking()
|
||||
.Include(rv => rv.AgrupacionPolitica)
|
||||
.Where(rv => rv.EleccionId == eleccionId && rv.CategoriaId == categoriaId && idsMunicipios.Contains(rv.AmbitoGeograficoId)).ToListAsync();
|
||||
|
||||
var todosLosCandidatos = await _dbContext.CandidatosOverrides.AsNoTracking()
|
||||
.Where(c => (c.EleccionId == eleccionId || c.EleccionId == 0) && c.CategoriaId == categoriaId).ToListAsync();
|
||||
|
||||
var resultadosFinales = new List<Elecciones.Core.DTOs.ApiResponses.Tablas.ResultadoSeccionDto>();
|
||||
|
||||
foreach (var seccion in secciones)
|
||||
{
|
||||
var seccionDto = new Elecciones.Core.DTOs.ApiResponses.Tablas.ResultadoSeccionDto { SeccionId = seccion.SeccionProvincialId!, Nombre = seccion.Nombre };
|
||||
var municipiosDeEstaSeccion = municipiosDeSecciones.Where(m => m.SeccionProvincialId == seccion.SeccionProvincialId).OrderBy(m => m.Nombre);
|
||||
|
||||
foreach (var municipio in municipiosDeEstaSeccion)
|
||||
{
|
||||
var votosMunicipio = todosLosVotos.Where(v => v.AmbitoGeograficoId == municipio.Id).ToList();
|
||||
var totalVotos = (decimal)votosMunicipio.Sum(v => v.CantidadVotos);
|
||||
var top2 = votosMunicipio.OrderByDescending(v => v.CantidadVotos).Take(2).ToList();
|
||||
|
||||
string GetDisplayName(ResultadoVoto? voto)
|
||||
{
|
||||
if (voto == null) return "N/D";
|
||||
|
||||
var candidato = todosLosCandidatos.FirstOrDefault(c => c.AgrupacionPoliticaId == voto.AgrupacionPoliticaId && c.AmbitoGeograficoId == voto.AmbitoGeograficoId)
|
||||
?? todosLosCandidatos.FirstOrDefault(c => c.AgrupacionPoliticaId == voto.AgrupacionPoliticaId && c.AmbitoGeograficoId == provinciaBA.Id)
|
||||
?? todosLosCandidatos.FirstOrDefault(c => c.AgrupacionPoliticaId == voto.AgrupacionPoliticaId && c.AmbitoGeograficoId == null);
|
||||
|
||||
return candidato?.NombreCandidato ?? voto.AgrupacionPolitica.NombreCorto ?? voto.AgrupacionPolitica.Nombre;
|
||||
}
|
||||
|
||||
seccionDto.Municipios.Add(new Elecciones.Core.DTOs.ApiResponses.Tablas.ResultadoFilaDto
|
||||
{
|
||||
AmbitoId = municipio.Id,
|
||||
Nombre = municipio.Nombre,
|
||||
Orden = municipio.Id,
|
||||
Fuerza1Display = GetDisplayName(top2.FirstOrDefault()),
|
||||
Fuerza1Porcentaje = top2.Count > 0 && totalVotos > 0 ? (top2[0].CantidadVotos / totalVotos) * 100 : 0,
|
||||
Fuerza2Display = GetDisplayName(top2.Skip(1).FirstOrDefault()),
|
||||
Fuerza2Porcentaje = top2.Count > 1 && totalVotos > 0 ? (top2[1].CantidadVotos / totalVotos) * 100 : 0,
|
||||
});
|
||||
}
|
||||
resultadosFinales.Add(seccionDto);
|
||||
}
|
||||
|
||||
return Ok(resultadosFinales);
|
||||
}
|
||||
|
||||
[HttpGet("resumen-nacional-por-provincia")]
|
||||
public async Task<IActionResult> GetResumenNacionalPorProvincia([FromRoute] int eleccionId, [FromQuery] int categoriaId)
|
||||
{
|
||||
// 1. Unir ResumenesVotos con AmbitosGeograficos para obtener la información necesaria de forma eficiente.
|
||||
var votosProvinciales = await (
|
||||
from rv in _dbContext.ResumenesVotos
|
||||
join ambito in _dbContext.AmbitosGeograficos on rv.AmbitoGeograficoId equals ambito.Id
|
||||
join agrupacion in _dbContext.AgrupacionesPoliticas on rv.AgrupacionPoliticaId equals agrupacion.Id
|
||||
where rv.EleccionId == eleccionId &&
|
||||
rv.CategoriaId == categoriaId &&
|
||||
ambito.NivelId == 10 // Aseguramos que solo tomamos datos a nivel provincial
|
||||
select new
|
||||
{
|
||||
ambito.DistritoId,
|
||||
ProvinciaNombre = ambito.Nombre,
|
||||
AgrupacionNombre = agrupacion.NombreCorto ?? agrupacion.Nombre,
|
||||
rv.Votos
|
||||
}
|
||||
).ToListAsync();
|
||||
|
||||
// 2. Obtener los estados de recuento para todas las provincias en una sola consulta.
|
||||
var todosLosEstados = await _dbContext.EstadosRecuentosGenerales
|
||||
.AsNoTracking()
|
||||
.Include(e => e.AmbitoGeografico)
|
||||
.Where(e => e.EleccionId == eleccionId &&
|
||||
e.CategoriaId == categoriaId &&
|
||||
e.AmbitoGeografico.NivelId == 10 &&
|
||||
e.AmbitoGeografico.DistritoId != null)
|
||||
.ToDictionaryAsync(e => e.AmbitoGeografico.DistritoId!);
|
||||
|
||||
// 3. Agrupar los resultados por provincia y procesarlos en memoria.
|
||||
var resultadoFinal = votosProvinciales
|
||||
.GroupBy(v => new { v.DistritoId, v.ProvinciaNombre })
|
||||
.Select(grupoProvincia =>
|
||||
{
|
||||
if (grupoProvincia.Key.DistritoId == null) return null;
|
||||
|
||||
var totalVotosProvincia = (decimal)grupoProvincia.Sum(p => p.Votos);
|
||||
if (totalVotosProvincia == 0) return null;
|
||||
|
||||
var top3Partidos = grupoProvincia
|
||||
.OrderByDescending(p => p.Votos)
|
||||
.Take(3)
|
||||
.Select(p => new Elecciones.Core.DTOs.ApiResponses.Resumen.PartidoResumenDto
|
||||
{
|
||||
Nombre = p.AgrupacionNombre,
|
||||
Porcentaje = (p.Votos / totalVotosProvincia) * 100
|
||||
})
|
||||
.ToList();
|
||||
|
||||
todosLosEstados.TryGetValue(grupoProvincia.Key.DistritoId, out var estado);
|
||||
|
||||
return new Elecciones.Core.DTOs.ApiResponses.Resumen.ProvinciaResumenDto
|
||||
{
|
||||
ProvinciaId = grupoProvincia.Key.DistritoId,
|
||||
ProvinciaNombre = grupoProvincia.Key.ProvinciaNombre,
|
||||
PorcentajeEscrutado = estado?.MesasTotalizadasPorcentaje ?? 0,
|
||||
Resultados = top3Partidos
|
||||
};
|
||||
})
|
||||
.Where(r => r != null) // Filtramos las provincias que no tuvieron votos o DistritoId nulo.
|
||||
.OrderBy(p => p!.ProvinciaNombre)
|
||||
.ToList();
|
||||
|
||||
return Ok(resultadoFinal);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,9 @@ using (var scope = app.Services.CreateScope())
|
||||
var logger = services.GetRequiredService<ILogger<Program>>();
|
||||
var hasher = services.GetRequiredService<IPasswordHasher>();
|
||||
|
||||
// --- PASO 1: Añadir esta bandera de control ---
|
||||
bool generarDatosDeEjemplo = false; // <-- Poner en 'false' para deshabilitar
|
||||
|
||||
// --- SEEDER 1: DATOS ESTRUCTURALES BÁSICOS (se ejecutan una sola vez si la BD está vacía) ---
|
||||
// Estos son los datos maestros que NUNCA cambian.
|
||||
|
||||
@@ -218,169 +221,171 @@ using (var scope = app.Services.CreateScope())
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Default configurations verified/seeded.");
|
||||
|
||||
|
||||
// --- SEEDER 2: DATOS DE EJEMPLO PARA ELECCIÓN NACIONAL (se ejecuta solo si faltan sus votos) ---
|
||||
const int eleccionNacionalId = 2;
|
||||
if (!await context.ResultadosVotos.AnyAsync(r => r.EleccionId == eleccionNacionalId))
|
||||
// --- PASO 2: Envolver todo el bloque del Seeder 2 en esta condición ---
|
||||
if (generarDatosDeEjemplo)
|
||||
{
|
||||
logger.LogInformation("--> No se encontraron datos de votos para la elección nacional ID {EleccionId}. Generando datos de simulación...", eleccionNacionalId);
|
||||
// --- SEEDER 2: DATOS DE EJEMPLO PARA ELECCIÓN NACIONAL (se ejecuta solo si faltan sus votos) ---
|
||||
const int eleccionNacionalId = 2;
|
||||
if (!await context.ResultadosVotos.AnyAsync(r => r.EleccionId == eleccionNacionalId))
|
||||
{
|
||||
logger.LogInformation("--> No se encontraron datos de votos para la elección nacional ID {EleccionId}. Generando datos de simulación...", eleccionNacionalId);
|
||||
|
||||
// PASO A: VERIFICAR/CREAR DEPENDENCIAS (Ámbitos, Categorías)
|
||||
if (!await context.CategoriasElectorales.AnyAsync(c => c.Id == 1))
|
||||
context.CategoriasElectorales.Add(new CategoriaElectoral { Id = 1, Nombre = "SENADORES NACIONALES", Orden = 2 });
|
||||
if (!await context.CategoriasElectorales.AnyAsync(c => c.Id == 2))
|
||||
context.CategoriasElectorales.Add(new CategoriaElectoral { Id = 2, Nombre = "DIPUTADOS NACIONALES", Orden = 3 });
|
||||
// PASO A: VERIFICAR/CREAR DEPENDENCIAS (Ámbitos, Categorías)
|
||||
if (!await context.CategoriasElectorales.AnyAsync(c => c.Id == 1))
|
||||
context.CategoriasElectorales.Add(new CategoriaElectoral { Id = 1, Nombre = "SENADORES NACIONALES", Orden = 2 });
|
||||
if (!await context.CategoriasElectorales.AnyAsync(c => c.Id == 2))
|
||||
context.CategoriasElectorales.Add(new CategoriaElectoral { Id = 2, Nombre = "DIPUTADOS NACIONALES", Orden = 3 });
|
||||
|
||||
var provinciasMaestras = new Dictionary<string, string> {
|
||||
var provinciasMaestras = new Dictionary<string, string> {
|
||||
{ "01", "CIUDAD AUTONOMA DE BUENOS AIRES" }, { "02", "BUENOS AIRES" }, { "03", "CATAMARCA" }, { "04", "CORDOBA" }, { "05", "CORRIENTES" },
|
||||
{ "06", "CHACO" }, { "07", "CHUBUT" }, { "08", "ENTRE RIOS" }, { "09", "FORMOSA" }, { "10", "JUJUY" }, { "11", "LA PAMPA" },
|
||||
{ "12", "LA RIOJA" }, { "13", "MENDOZA" }, { "14", "MISIONES" }, { "15", "NEUQUEN" }, { "16", "RIO NEGRO" }, { "17", "SALTA" },
|
||||
{ "18", "SAN JUAN" }, { "19", "SAN LUIS" }, { "20", "SANTA CRUZ" }, { "21", "SANTA FE" }, { "22", "SANTIAGO DEL ESTERO" },
|
||||
{ "23", "TIERRA DEL FUEGO" }, { "24", "TUCUMAN" }
|
||||
};
|
||||
foreach (var p in provinciasMaestras)
|
||||
{
|
||||
if (!await context.AmbitosGeograficos.AnyAsync(a => a.NivelId == 10 && a.DistritoId == p.Key))
|
||||
context.AmbitosGeograficos.Add(new AmbitoGeografico { Nombre = p.Value, NivelId = 10, DistritoId = p.Key });
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var provinciasEnDb = await context.AmbitosGeograficos.AsNoTracking().Where(a => a.NivelId == 10).ToListAsync();
|
||||
foreach (var provincia in provinciasEnDb)
|
||||
{
|
||||
if (!await context.AmbitosGeograficos.AnyAsync(a => a.NivelId == 30 && a.DistritoId == provincia.DistritoId))
|
||||
foreach (var p in provinciasMaestras)
|
||||
{
|
||||
for (int i = 1; i <= 5; i++)
|
||||
context.AmbitosGeograficos.Add(new AmbitoGeografico { Nombre = $"{provincia.Nombre} - Depto. {i}", NivelId = 30, DistritoId = provincia.DistritoId });
|
||||
if (!await context.AmbitosGeograficos.AnyAsync(a => a.NivelId == 10 && a.DistritoId == p.Key))
|
||||
context.AmbitosGeograficos.Add(new AmbitoGeografico { Nombre = p.Value, NivelId = 10, DistritoId = p.Key });
|
||||
}
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Datos maestros para Elección Nacional (Ámbitos, Categorías) verificados/creados.");
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// PASO B: GENERAR DATOS TRANSACCIONALES (Votos, Recuentos, etc.)
|
||||
var todosLosPartidos = await context.AgrupacionesPoliticas.Take(5).ToListAsync();
|
||||
if (!todosLosPartidos.Any())
|
||||
{
|
||||
logger.LogWarning("--> No hay partidos en la BD, no se pueden generar votos de ejemplo.");
|
||||
return; // Salir si no hay partidos para evitar errores
|
||||
}
|
||||
|
||||
// (La lógica interna de generación de votos y recuentos que ya tenías y funcionaba)
|
||||
// ... (el código de generación de `nuevosResultados` y `nuevosEstados` va aquí, sin cambios)
|
||||
var nuevosResultados = new List<ResultadoVoto>();
|
||||
var nuevosEstados = new List<EstadoRecuentoGeneral>();
|
||||
var rand = new Random();
|
||||
var provinciasQueRenuevanSenadores = new HashSet<string> { "01", "06", "08", "15", "16", "17", "22", "23" };
|
||||
var categoriaDiputadosNac = await context.CategoriasElectorales.FindAsync(2);
|
||||
var categoriaSenadoresNac = await context.CategoriasElectorales.FindAsync(1);
|
||||
|
||||
long totalVotosNacionalDip = 0, totalVotosNacionalSen = 0;
|
||||
int totalMesasNacionalDip = 0, totalMesasNacionalSen = 0;
|
||||
int totalMesasEscrutadasNacionalDip = 0, totalMesasEscrutadasNacionalSen = 0;
|
||||
|
||||
foreach (var provincia in provinciasEnDb)
|
||||
{
|
||||
var municipiosDeProvincia = await context.AmbitosGeograficos.AsNoTracking().Where(a => a.NivelId == 30 && a.DistritoId == provincia.DistritoId).ToListAsync();
|
||||
if (!municipiosDeProvincia.Any()) continue;
|
||||
|
||||
var categoriasParaProcesar = new List<CategoriaElectoral> { categoriaDiputadosNac! };
|
||||
if (provinciasQueRenuevanSenadores.Contains(provincia.DistritoId!))
|
||||
categoriasParaProcesar.Add(categoriaSenadoresNac!);
|
||||
|
||||
foreach (var categoria in categoriasParaProcesar)
|
||||
var provinciasEnDb = await context.AmbitosGeograficos.AsNoTracking().Where(a => a.NivelId == 10).ToListAsync();
|
||||
foreach (var provincia in provinciasEnDb)
|
||||
{
|
||||
long totalVotosProvinciaCategoria = 0;
|
||||
int partidoIndex = rand.Next(todosLosPartidos.Count);
|
||||
foreach (var municipio in municipiosDeProvincia)
|
||||
if (!await context.AmbitosGeograficos.AnyAsync(a => a.NivelId == 30 && a.DistritoId == provincia.DistritoId))
|
||||
{
|
||||
var partidoGanador = todosLosPartidos[partidoIndex++ % todosLosPartidos.Count];
|
||||
var votosGanador = rand.Next(25000, 70000);
|
||||
nuevosResultados.Add(new ResultadoVoto { EleccionId = eleccionNacionalId, AmbitoGeograficoId = municipio.Id, CategoriaId = categoria.Id, AgrupacionPoliticaId = partidoGanador.Id, CantidadVotos = votosGanador });
|
||||
totalVotosProvinciaCategoria += votosGanador;
|
||||
var otrosPartidos = todosLosPartidos.Where(p => p.Id != partidoGanador.Id).OrderBy(p => rand.Next()).Take(rand.Next(3, todosLosPartidos.Count));
|
||||
foreach (var competidor in otrosPartidos)
|
||||
for (int i = 1; i <= 5; i++)
|
||||
context.AmbitosGeograficos.Add(new AmbitoGeografico { Nombre = $"{provincia.Nombre} - Depto. {i}", NivelId = 30, DistritoId = provincia.DistritoId });
|
||||
}
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Datos maestros para Elección Nacional (Ámbitos, Categorías) verificados/creados.");
|
||||
|
||||
// PASO B: GENERAR DATOS TRANSACCIONALES (Votos, Recuentos, etc.)
|
||||
var todosLosPartidos = await context.AgrupacionesPoliticas.Take(5).ToListAsync();
|
||||
if (!todosLosPartidos.Any())
|
||||
{
|
||||
logger.LogWarning("--> No hay partidos en la BD, no se pueden generar votos de ejemplo.");
|
||||
return; // Salir si no hay partidos para evitar errores
|
||||
}
|
||||
|
||||
// (La lógica interna de generación de votos y recuentos que ya tenías y funcionaba)
|
||||
// ... (el código de generación de `nuevosResultados` y `nuevosEstados` va aquí, sin cambios)
|
||||
var nuevosResultados = new List<ResultadoVoto>();
|
||||
var nuevosEstados = new List<EstadoRecuentoGeneral>();
|
||||
var rand = new Random();
|
||||
var provinciasQueRenuevanSenadores = new HashSet<string> { "01", "06", "08", "15", "16", "17", "22", "23" };
|
||||
var categoriaDiputadosNac = await context.CategoriasElectorales.FindAsync(2);
|
||||
var categoriaSenadoresNac = await context.CategoriasElectorales.FindAsync(1);
|
||||
|
||||
long totalVotosNacionalDip = 0, totalVotosNacionalSen = 0;
|
||||
int totalMesasNacionalDip = 0, totalMesasNacionalSen = 0;
|
||||
int totalMesasEscrutadasNacionalDip = 0, totalMesasEscrutadasNacionalSen = 0;
|
||||
|
||||
foreach (var provincia in provinciasEnDb)
|
||||
{
|
||||
var municipiosDeProvincia = await context.AmbitosGeograficos.AsNoTracking().Where(a => a.NivelId == 30 && a.DistritoId == provincia.DistritoId).ToListAsync();
|
||||
if (!municipiosDeProvincia.Any()) continue;
|
||||
|
||||
var categoriasParaProcesar = new List<CategoriaElectoral> { categoriaDiputadosNac! };
|
||||
if (provinciasQueRenuevanSenadores.Contains(provincia.DistritoId!))
|
||||
categoriasParaProcesar.Add(categoriaSenadoresNac!);
|
||||
|
||||
foreach (var categoria in categoriasParaProcesar)
|
||||
{
|
||||
long totalVotosProvinciaCategoria = 0;
|
||||
int partidoIndex = rand.Next(todosLosPartidos.Count);
|
||||
foreach (var municipio in municipiosDeProvincia)
|
||||
{
|
||||
var votosCompetidor = rand.Next(1000, 24000);
|
||||
nuevosResultados.Add(new ResultadoVoto { EleccionId = eleccionNacionalId, AmbitoGeograficoId = municipio.Id, CategoriaId = categoria.Id, AgrupacionPoliticaId = competidor.Id, CantidadVotos = votosCompetidor });
|
||||
totalVotosProvinciaCategoria += votosCompetidor;
|
||||
var partidoGanador = todosLosPartidos[partidoIndex++ % todosLosPartidos.Count];
|
||||
var votosGanador = rand.Next(25000, 70000);
|
||||
nuevosResultados.Add(new ResultadoVoto { EleccionId = eleccionNacionalId, AmbitoGeograficoId = municipio.Id, CategoriaId = categoria.Id, AgrupacionPoliticaId = partidoGanador.Id, CantidadVotos = votosGanador });
|
||||
totalVotosProvinciaCategoria += votosGanador;
|
||||
var otrosPartidos = todosLosPartidos.Where(p => p.Id != partidoGanador.Id).OrderBy(p => rand.Next()).Take(rand.Next(3, todosLosPartidos.Count));
|
||||
foreach (var competidor in otrosPartidos)
|
||||
{
|
||||
var votosCompetidor = rand.Next(1000, 24000);
|
||||
nuevosResultados.Add(new ResultadoVoto { EleccionId = eleccionNacionalId, AmbitoGeograficoId = municipio.Id, CategoriaId = categoria.Id, AgrupacionPoliticaId = competidor.Id, CantidadVotos = votosCompetidor });
|
||||
totalVotosProvinciaCategoria += votosCompetidor;
|
||||
}
|
||||
}
|
||||
var mesasEsperadasProvincia = municipiosDeProvincia.Count * rand.Next(15, 30);
|
||||
var mesasTotalizadasProvincia = (int)(mesasEsperadasProvincia * (rand.Next(75, 99) / 100.0));
|
||||
var cantidadElectoresProvincia = mesasEsperadasProvincia * 350;
|
||||
var participacionProvincia = (decimal)(rand.Next(65, 85) / 100.0);
|
||||
nuevosEstados.Add(new EstadoRecuentoGeneral
|
||||
{
|
||||
EleccionId = eleccionNacionalId,
|
||||
AmbitoGeograficoId = provincia.Id,
|
||||
CategoriaId = categoria.Id,
|
||||
FechaTotalizacion = DateTime.UtcNow,
|
||||
MesasEsperadas = mesasEsperadasProvincia,
|
||||
MesasTotalizadas = mesasTotalizadasProvincia,
|
||||
MesasTotalizadasPorcentaje = mesasEsperadasProvincia > 0 ? (decimal)mesasTotalizadasProvincia * 100 / mesasEsperadasProvincia : 0,
|
||||
CantidadElectores = cantidadElectoresProvincia,
|
||||
CantidadVotantes = (int)(cantidadElectoresProvincia * participacionProvincia),
|
||||
ParticipacionPorcentaje = participacionProvincia * 100
|
||||
});
|
||||
if (categoriaDiputadosNac != null && categoria.Id == categoriaDiputadosNac.Id)
|
||||
{
|
||||
totalVotosNacionalDip += totalVotosProvinciaCategoria; totalMesasNacionalDip += mesasEsperadasProvincia; totalMesasEscrutadasNacionalDip += mesasTotalizadasProvincia;
|
||||
}
|
||||
else
|
||||
{
|
||||
totalVotosNacionalSen += totalVotosProvinciaCategoria; totalMesasNacionalSen += mesasEsperadasProvincia; totalMesasEscrutadasNacionalSen += mesasTotalizadasProvincia;
|
||||
}
|
||||
}
|
||||
var mesasEsperadasProvincia = municipiosDeProvincia.Count * rand.Next(15, 30);
|
||||
var mesasTotalizadasProvincia = (int)(mesasEsperadasProvincia * (rand.Next(75, 99) / 100.0));
|
||||
var cantidadElectoresProvincia = mesasEsperadasProvincia * 350;
|
||||
var participacionProvincia = (decimal)(rand.Next(65, 85) / 100.0);
|
||||
}
|
||||
var ambitoNacional = await context.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 0);
|
||||
if (ambitoNacional != null && categoriaDiputadosNac != null && categoriaSenadoresNac != null)
|
||||
{
|
||||
var participacionNacionalDip = (decimal)(rand.Next(70, 88) / 100.0);
|
||||
nuevosEstados.Add(new EstadoRecuentoGeneral
|
||||
{
|
||||
EleccionId = eleccionNacionalId,
|
||||
AmbitoGeograficoId = provincia.Id,
|
||||
CategoriaId = categoria.Id,
|
||||
AmbitoGeograficoId = ambitoNacional.Id,
|
||||
CategoriaId = categoriaDiputadosNac.Id,
|
||||
FechaTotalizacion = DateTime.UtcNow,
|
||||
MesasEsperadas = mesasEsperadasProvincia,
|
||||
MesasTotalizadas = mesasTotalizadasProvincia,
|
||||
MesasTotalizadasPorcentaje = mesasEsperadasProvincia > 0 ? (decimal)mesasTotalizadasProvincia * 100 / mesasEsperadasProvincia : 0,
|
||||
CantidadElectores = cantidadElectoresProvincia,
|
||||
CantidadVotantes = (int)(cantidadElectoresProvincia * participacionProvincia),
|
||||
ParticipacionPorcentaje = participacionProvincia * 100
|
||||
MesasEsperadas = totalMesasNacionalDip,
|
||||
MesasTotalizadas = totalMesasEscrutadasNacionalDip,
|
||||
MesasTotalizadasPorcentaje = totalMesasNacionalDip > 0 ? (decimal)totalMesasEscrutadasNacionalDip * 100 / totalMesasNacionalDip : 0,
|
||||
CantidadElectores = totalMesasNacionalDip * 350,
|
||||
CantidadVotantes = (int)((totalMesasNacionalDip * 350) * participacionNacionalDip),
|
||||
ParticipacionPorcentaje = participacionNacionalDip * 100
|
||||
});
|
||||
if (categoriaDiputadosNac != null && categoria.Id == categoriaDiputadosNac.Id)
|
||||
var participacionNacionalSen = (decimal)(rand.Next(70, 88) / 100.0);
|
||||
nuevosEstados.Add(new EstadoRecuentoGeneral
|
||||
{
|
||||
totalVotosNacionalDip += totalVotosProvinciaCategoria; totalMesasNacionalDip += mesasEsperadasProvincia; totalMesasEscrutadasNacionalDip += mesasTotalizadasProvincia;
|
||||
}
|
||||
else
|
||||
{
|
||||
totalVotosNacionalSen += totalVotosProvinciaCategoria; totalMesasNacionalSen += mesasEsperadasProvincia; totalMesasEscrutadasNacionalSen += mesasTotalizadasProvincia;
|
||||
}
|
||||
EleccionId = eleccionNacionalId,
|
||||
AmbitoGeograficoId = ambitoNacional.Id,
|
||||
CategoriaId = categoriaSenadoresNac.Id,
|
||||
FechaTotalizacion = DateTime.UtcNow,
|
||||
MesasEsperadas = totalMesasNacionalSen,
|
||||
MesasTotalizadas = totalMesasEscrutadasNacionalSen,
|
||||
MesasTotalizadasPorcentaje = totalMesasNacionalSen > 0 ? (decimal)totalMesasEscrutadasNacionalSen * 100 / totalMesasNacionalSen : 0,
|
||||
CantidadElectores = totalMesasNacionalSen * 350,
|
||||
CantidadVotantes = (int)((totalMesasNacionalSen * 350) * participacionNacionalSen),
|
||||
ParticipacionPorcentaje = participacionNacionalSen * 100
|
||||
});
|
||||
}
|
||||
}
|
||||
var ambitoNacional = await context.AmbitosGeograficos.AsNoTracking().FirstOrDefaultAsync(a => a.NivelId == 0);
|
||||
if (ambitoNacional != null && categoriaDiputadosNac != null && categoriaSenadoresNac != null)
|
||||
{
|
||||
var participacionNacionalDip = (decimal)(rand.Next(70, 88) / 100.0);
|
||||
nuevosEstados.Add(new EstadoRecuentoGeneral
|
||||
else
|
||||
{
|
||||
EleccionId = eleccionNacionalId,
|
||||
AmbitoGeograficoId = ambitoNacional.Id,
|
||||
CategoriaId = categoriaDiputadosNac.Id,
|
||||
FechaTotalizacion = DateTime.UtcNow,
|
||||
MesasEsperadas = totalMesasNacionalDip,
|
||||
MesasTotalizadas = totalMesasEscrutadasNacionalDip,
|
||||
MesasTotalizadasPorcentaje = totalMesasNacionalDip > 0 ? (decimal)totalMesasEscrutadasNacionalDip * 100 / totalMesasNacionalDip : 0,
|
||||
CantidadElectores = totalMesasNacionalDip * 350,
|
||||
CantidadVotantes = (int)((totalMesasNacionalDip * 350) * participacionNacionalDip),
|
||||
ParticipacionPorcentaje = participacionNacionalDip * 100
|
||||
});
|
||||
var participacionNacionalSen = (decimal)(rand.Next(70, 88) / 100.0);
|
||||
nuevosEstados.Add(new EstadoRecuentoGeneral
|
||||
logger.LogWarning("--> No se encontró el ámbito nacional (NivelId == 0) o las categorías electorales nacionales. No se agregaron estados nacionales.");
|
||||
}
|
||||
|
||||
if (nuevosResultados.Any())
|
||||
{
|
||||
EleccionId = eleccionNacionalId,
|
||||
AmbitoGeograficoId = ambitoNacional.Id,
|
||||
CategoriaId = categoriaSenadoresNac.Id,
|
||||
FechaTotalizacion = DateTime.UtcNow,
|
||||
MesasEsperadas = totalMesasNacionalSen,
|
||||
MesasTotalizadas = totalMesasEscrutadasNacionalSen,
|
||||
MesasTotalizadasPorcentaje = totalMesasNacionalSen > 0 ? (decimal)totalMesasEscrutadasNacionalSen * 100 / totalMesasNacionalSen : 0,
|
||||
CantidadElectores = totalMesasNacionalSen * 350,
|
||||
CantidadVotantes = (int)((totalMesasNacionalSen * 350) * participacionNacionalSen),
|
||||
ParticipacionPorcentaje = participacionNacionalSen * 100
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("--> No se encontró el ámbito nacional (NivelId == 0) o las categorías electorales nacionales. No se agregaron estados nacionales.");
|
||||
}
|
||||
await context.ResultadosVotos.AddRangeAsync(nuevosResultados);
|
||||
await context.EstadosRecuentosGenerales.AddRangeAsync(nuevosEstados);
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Se generaron {Votos} registros de votos y {Estados} de estados de recuento.", nuevosResultados.Count, nuevosEstados.Count);
|
||||
}
|
||||
|
||||
if (nuevosResultados.Any())
|
||||
{
|
||||
await context.ResultadosVotos.AddRangeAsync(nuevosResultados);
|
||||
await context.EstadosRecuentosGenerales.AddRangeAsync(nuevosEstados);
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Se generaron {Votos} registros de votos y {Estados} de estados de recuento.", nuevosResultados.Count, nuevosEstados.Count);
|
||||
}
|
||||
|
||||
// PASO C: GENERAR BANCAS PREVIAS Y PROYECCIONES
|
||||
if (!await context.BancasPrevias.AnyAsync(b => b.EleccionId == eleccionNacionalId))
|
||||
{
|
||||
var bancasPrevias = new List<BancaPrevia> {
|
||||
// PASO C: GENERAR BANCAS PREVIAS Y PROYECCIONES
|
||||
if (!await context.BancasPrevias.AnyAsync(b => b.EleccionId == eleccionNacionalId))
|
||||
{
|
||||
var bancasPrevias = new List<BancaPrevia> {
|
||||
new() { EleccionId = eleccionNacionalId, Camara = TipoCamara.Diputados, AgrupacionPoliticaId = todosLosPartidos[0].Id, Cantidad = 40 },
|
||||
new() { EleccionId = eleccionNacionalId, Camara = TipoCamara.Diputados, AgrupacionPoliticaId = todosLosPartidos[1].Id, Cantidad = 35 },
|
||||
new() { EleccionId = eleccionNacionalId, Camara = TipoCamara.Diputados, AgrupacionPoliticaId = todosLosPartidos[2].Id, Cantidad = 30 },
|
||||
@@ -392,9 +397,10 @@ using (var scope = app.Services.CreateScope())
|
||||
new() { EleccionId = eleccionNacionalId, Camara = TipoCamara.Senadores, AgrupacionPoliticaId = todosLosPartidos[3].Id, Cantidad = 4 },
|
||||
new() { EleccionId = eleccionNacionalId, Camara = TipoCamara.Senadores, AgrupacionPoliticaId = todosLosPartidos[4].Id, Cantidad = 3 },
|
||||
};
|
||||
await context.BancasPrevias.AddRangeAsync(bancasPrevias);
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Seeded Bancas Previas para la Elección Nacional.");
|
||||
await context.BancasPrevias.AddRangeAsync(bancasPrevias);
|
||||
await context.SaveChangesAsync();
|
||||
logger.LogInformation("--> Seeded Bancas Previas para la Elección Nacional.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Api")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+11d9417ef5e3645d51c0ab227a0804985db4b1fb")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8d7f5c1db6f69f3e6bee29c32b877e64080c45ed")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Api")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Api")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["VUEzgM3q00ehIicPw1uM8REVOid2zDpFbcwF9rvWytQ=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","TEsXImnzxFKTIq2f5fiDu7i6Ar/cbecW5MZ3z8Wb/a4=","5WogJu\u002BUPlF\u002BE5mq/ILtDXpVwqwmhHtsEB13nmT5JJk=","dcHQRkttjMjo2dvhL7hA9t4Pg\u002B7OnjZpkFmakT4QR9U=","Of8nTYw5l\u002BgiAJo7z6XYIntG2tUtCFcILzHbTiiXn\u002Bw=","PDy\u002BTiayvNAoXXBEgwC/kCojpgOOMI6RQOIoSXs3LJc=","ePXrkee3hv3wHUr8S7aYmRVvXUTxQf76zApKGv3/l3o=","DXx5dQywLo3UsY2zQaUG\u002BbW4ObiYbybxPBWxeJD2bhk=","muVh5sjH3sgdvuz4TbuTwTggX1uDnsWXgoosMKST/r4=","nrP5gSIA5vzgp8v12CAOr943QYLxU4Til6oiCcWSNI8=","yMd45U9BK07I3b3fBQ627PWTYyZ2ZjrmFc5VD\u002BQVx1Q=","xKskvcoJU0RVRN1a5dRqKRM7IP5vmmbraUaPFYjhnCc=","p7BjQw7aSZjfOCqmKm7/kPO9qegEQZBfirMjlOx/I1I=","MI0hVVLYavEhzHq/Z1UbajfrxanA1aET19aOH8G2ImI=","2dY8CqW9fAY8yN0foa\u002BZp2gc0RfPoPmB/tKSj1QoTw0=","79rfGLH4UjfTPvc//\u002BZjnBqdz585pUtYZ0/hwE2iEic=","PUqgvMdfTQkF5lpBVtHv2teQLV5WaEH0xMKTmINe2YQ=","\u002BFI0b4ppdxel/pby/y/xKImHrtdxo2g83OhskdREyIg=","jEESu6\u002BhbDvNMjLt/6OufuK\u002B9cHmzx\u002BTCIn4fWa9nSc=","UaCPJEvR4nVxxGCB5CUnRlJiw4drDW3Q3Nss\u002Bya2cv4=","ZqF13CT3rok/Gzl\u002BMsw3q9X1nf65bwEVD670efE3k\u002Bk=","gH3W7phPzBCY1DAVn4YnP4SA8Uaq73TpctS0yFSvzNM=","u5F4J4\u002BLHUIOCz5ze5NSF42mDeAaAfi\u002BKN3Ay3rKLY8=","GeUUID0ymF5rrBWdX7YHzWA5GiGkNWCNUog4sp4xL3c=","3BxX4I0JXoDqmE8m0BrRZhixBRlHEueS3jAlmUXE/I8=","s3VnfR5av25jQd9RIy\u002BMwczWKx/CJRSzFdhJAMaIN9k=","A\u002BWemDKn7UwHxqDXzVs57jXOqpea86CLYpxVWDzRnDo=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","jAqiyVzpgWGABD/mtww8iBZneRW/QcFx5ww\u002BXozPGx4="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"b5T/+ta4fUd8qpIzUTm3KyEwAYYUsU5ASo+CSFM3ByE=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["YB39loxHH43S4MF8aTOiogcIbBAIq5Qj3dlJkIfYVxI=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","yMd45U9BK07I3b3fBQ627PWTYyZ2ZjrmFc5VD\u002BQVx1Q=","xKskvcoJU0RVRN1a5dRqKRM7IP5vmmbraUaPFYjhnCc=","p7BjQw7aSZjfOCqmKm7/kPO9qegEQZBfirMjlOx/I1I=","MI0hVVLYavEhzHq/Z1UbajfrxanA1aET19aOH8G2ImI=","2dY8CqW9fAY8yN0foa\u002BZp2gc0RfPoPmB/tKSj1QoTw0=","79rfGLH4UjfTPvc//\u002BZjnBqdz585pUtYZ0/hwE2iEic=","PUqgvMdfTQkF5lpBVtHv2teQLV5WaEH0xMKTmINe2YQ=","\u002BFI0b4ppdxel/pby/y/xKImHrtdxo2g83OhskdREyIg=","jEESu6\u002BhbDvNMjLt/6OufuK\u002B9cHmzx\u002BTCIn4fWa9nSc=","UaCPJEvR4nVxxGCB5CUnRlJiw4drDW3Q3Nss\u002Bya2cv4=","ZqF13CT3rok/Gzl\u002BMsw3q9X1nf65bwEVD670efE3k\u002Bk=","gH3W7phPzBCY1DAVn4YnP4SA8Uaq73TpctS0yFSvzNM=","u5F4J4\u002BLHUIOCz5ze5NSF42mDeAaAfi\u002BKN3Ay3rKLY8=","GeUUID0ymF5rrBWdX7YHzWA5GiGkNWCNUog4sp4xL3c=","3BxX4I0JXoDqmE8m0BrRZhixBRlHEueS3jAlmUXE/I8=","IlET7uqumshgFxIEvfKRskON\u002BeAKZ7OfD/kCeAwn0PM=","NN2rS\u002B89ZAITWlNODPcF/lHIh3ZNmAHvUX4EjqSkX4s=","OE89N/FsYhRU1Dy5Ne83ehzSwlNc/RcxHrJpHxPHfqY=","QI7IL4TkYEqfUiIEXQiVCaZx4vrM9/wZlvOrhnUd4jQ=","UIntj4QoiyGr7bnJN8KK5PGrhQd89m\u002BLfh4T8VKPxAk=","J\u002Bfv/j3QyIW9bxolc46wDka8641F622/QgIllt0Re80=","Y/o0rakw9VYzEfz9M659qW77P9kvz\u002B2gTe1Lv3zgUDE=","8QWUReqP8upfOnmA5lMNgBxAfYJ1z3zv/WYBUXBEiog=","1L7p1HQI/Uoosqm7RyBuYjKbRFTycFgJEtHPSdlXWhU=","ZxPpBx5gkHuilHLcg/vcjvaXswvTqiUM0YaAEwbNSLI=","zSbNtRd32h6wCMWjU5ecl5a3ECd\u002BVBstFC3etkdk4s0=","urIQ/RlknPjR8\u002BeAcCsDIPiRjQGFfUdIC\u002BoT3wYB2dU=","ytyPPQGU70eGo9tCrHq5\u002BwXF3yVuqv9Z\u002Br1Zdf0XUCI=","jOi/jUJ7o3KWHJ\u002BTUu1vZl6Z/94v2iG7KCnN4hr9IxM=","EX3cE3dtzg9OULBi66wvzoTBda5oAAEkcAgb2vzAcRE=","i\u002BlquWEXQionduujlv277Sj/bnYmn0JtYjUAR5GjYbI=","BY4GeeFiQbYpWuSzb2XIY4JatmLNOZ6dhKs4ZT92nsM=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","iCYEGzjxtMCF9Bx2KuOz28eenE\u002Bzi818/JImx0MXYn0="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
@@ -1 +1 @@
|
||||
{"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["VUEzgM3q00ehIicPw1uM8REVOid2zDpFbcwF9rvWytQ=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","ezUlOMzNZmyKDIe1wwXqvX\u002BvhwfB992xNVts7r2zcTc=","y2BV4WpkQuLfqQhfOQBtmuzh940c3s4LAopGKfztfTE=","lHTUEsMkDu8nqXtfTwl7FRfgocyyc7RI5O/edTHN1\u002B0=","A7nz7qgOtQ1CwZZLvNnr0b5QZB3fTi3y4i6y7rBIcxQ=","znnuRi2tsk7AACuYo4WSgj7NcLriG4PKVaF4L35SvDk=","GelE32odx/vTului267wqi6zL3abBnF9yvwC2Q66LoM=","TEsXImnzxFKTIq2f5fiDu7i6Ar/cbecW5MZ3z8Wb/a4=","5WogJu\u002BUPlF\u002BE5mq/ILtDXpVwqwmhHtsEB13nmT5JJk=","dcHQRkttjMjo2dvhL7hA9t4Pg\u002B7OnjZpkFmakT4QR9U=","Of8nTYw5l\u002BgiAJo7z6XYIntG2tUtCFcILzHbTiiXn\u002Bw=","PDy\u002BTiayvNAoXXBEgwC/kCojpgOOMI6RQOIoSXs3LJc=","ePXrkee3hv3wHUr8S7aYmRVvXUTxQf76zApKGv3/l3o=","DXx5dQywLo3UsY2zQaUG\u002BbW4ObiYbybxPBWxeJD2bhk=","muVh5sjH3sgdvuz4TbuTwTggX1uDnsWXgoosMKST/r4=","nrP5gSIA5vzgp8v12CAOr943QYLxU4Til6oiCcWSNI8=","yMd45U9BK07I3b3fBQ627PWTYyZ2ZjrmFc5VD\u002BQVx1Q=","xKskvcoJU0RVRN1a5dRqKRM7IP5vmmbraUaPFYjhnCc=","p7BjQw7aSZjfOCqmKm7/kPO9qegEQZBfirMjlOx/I1I=","MI0hVVLYavEhzHq/Z1UbajfrxanA1aET19aOH8G2ImI=","2dY8CqW9fAY8yN0foa\u002BZp2gc0RfPoPmB/tKSj1QoTw0=","79rfGLH4UjfTPvc//\u002BZjnBqdz585pUtYZ0/hwE2iEic=","PUqgvMdfTQkF5lpBVtHv2teQLV5WaEH0xMKTmINe2YQ=","\u002BFI0b4ppdxel/pby/y/xKImHrtdxo2g83OhskdREyIg=","jEESu6\u002BhbDvNMjLt/6OufuK\u002B9cHmzx\u002BTCIn4fWa9nSc=","UaCPJEvR4nVxxGCB5CUnRlJiw4drDW3Q3Nss\u002Bya2cv4=","ZqF13CT3rok/Gzl\u002BMsw3q9X1nf65bwEVD670efE3k\u002Bk=","gH3W7phPzBCY1DAVn4YnP4SA8Uaq73TpctS0yFSvzNM=","u5F4J4\u002BLHUIOCz5ze5NSF42mDeAaAfi\u002BKN3Ay3rKLY8=","GeUUID0ymF5rrBWdX7YHzWA5GiGkNWCNUog4sp4xL3c=","3BxX4I0JXoDqmE8m0BrRZhixBRlHEueS3jAlmUXE/I8=","s3VnfR5av25jQd9RIy\u002BMwczWKx/CJRSzFdhJAMaIN9k=","A\u002BWemDKn7UwHxqDXzVs57jXOqpea86CLYpxVWDzRnDo=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","jAqiyVzpgWGABD/mtww8iBZneRW/QcFx5ww\u002BXozPGx4="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"tJTBjV/i0Ihkc6XuOu69wxL8PBac9c9Kak6srMso4pU=","FingerprintPatternsHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["YB39loxHH43S4MF8aTOiogcIbBAIq5Qj3dlJkIfYVxI=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM=","PA/Beu9jJpOBY5r5Y1CiSyqrARA2j7LHeWYUnEZpQO8=","E2ODTAlJxzsXY1iP1eB/02NIUK\u002BnQveGlWAOHY1cpgA=","yMd45U9BK07I3b3fBQ627PWTYyZ2ZjrmFc5VD\u002BQVx1Q=","xKskvcoJU0RVRN1a5dRqKRM7IP5vmmbraUaPFYjhnCc=","p7BjQw7aSZjfOCqmKm7/kPO9qegEQZBfirMjlOx/I1I=","MI0hVVLYavEhzHq/Z1UbajfrxanA1aET19aOH8G2ImI=","2dY8CqW9fAY8yN0foa\u002BZp2gc0RfPoPmB/tKSj1QoTw0=","79rfGLH4UjfTPvc//\u002BZjnBqdz585pUtYZ0/hwE2iEic=","PUqgvMdfTQkF5lpBVtHv2teQLV5WaEH0xMKTmINe2YQ=","\u002BFI0b4ppdxel/pby/y/xKImHrtdxo2g83OhskdREyIg=","jEESu6\u002BhbDvNMjLt/6OufuK\u002B9cHmzx\u002BTCIn4fWa9nSc=","UaCPJEvR4nVxxGCB5CUnRlJiw4drDW3Q3Nss\u002Bya2cv4=","ZqF13CT3rok/Gzl\u002BMsw3q9X1nf65bwEVD670efE3k\u002Bk=","gH3W7phPzBCY1DAVn4YnP4SA8Uaq73TpctS0yFSvzNM=","u5F4J4\u002BLHUIOCz5ze5NSF42mDeAaAfi\u002BKN3Ay3rKLY8=","GeUUID0ymF5rrBWdX7YHzWA5GiGkNWCNUog4sp4xL3c=","3BxX4I0JXoDqmE8m0BrRZhixBRlHEueS3jAlmUXE/I8=","IlET7uqumshgFxIEvfKRskON\u002BeAKZ7OfD/kCeAwn0PM=","NN2rS\u002B89ZAITWlNODPcF/lHIh3ZNmAHvUX4EjqSkX4s=","OE89N/FsYhRU1Dy5Ne83ehzSwlNc/RcxHrJpHxPHfqY=","QI7IL4TkYEqfUiIEXQiVCaZx4vrM9/wZlvOrhnUd4jQ=","UIntj4QoiyGr7bnJN8KK5PGrhQd89m\u002BLfh4T8VKPxAk=","J\u002Bfv/j3QyIW9bxolc46wDka8641F622/QgIllt0Re80=","Y/o0rakw9VYzEfz9M659qW77P9kvz\u002B2gTe1Lv3zgUDE=","8QWUReqP8upfOnmA5lMNgBxAfYJ1z3zv/WYBUXBEiog=","1L7p1HQI/Uoosqm7RyBuYjKbRFTycFgJEtHPSdlXWhU=","ZxPpBx5gkHuilHLcg/vcjvaXswvTqiUM0YaAEwbNSLI=","zSbNtRd32h6wCMWjU5ecl5a3ECd\u002BVBstFC3etkdk4s0=","urIQ/RlknPjR8\u002BeAcCsDIPiRjQGFfUdIC\u002BoT3wYB2dU=","ytyPPQGU70eGo9tCrHq5\u002BwXF3yVuqv9Z\u002Br1Zdf0XUCI=","jOi/jUJ7o3KWHJ\u002BTUu1vZl6Z/94v2iG7KCnN4hr9IxM=","EX3cE3dtzg9OULBi66wvzoTBda5oAAEkcAgb2vzAcRE=","i\u002BlquWEXQionduujlv277Sj/bnYmn0JtYjUAR5GjYbI=","BY4GeeFiQbYpWuSzb2XIY4JatmLNOZ6dhKs4ZT92nsM=","P8JRhYPpULTLMAydvl3Ky\u002B92/tYDIjui0l66En4aXuQ=","iCYEGzjxtMCF9Bx2KuOz28eenE\u002Bzi818/JImx0MXYn0="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
@@ -1 +1 @@
|
||||
{"GlobalPropertiesHash":"O7YawHw32G/Fh2bs+snZgm9O7okI0WYgTQmXM931znY=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["VUEzgM3q00ehIicPw1uM8REVOid2zDpFbcwF9rvWytQ=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"O7YawHw32G/Fh2bs+snZgm9O7okI0WYgTQmXM931znY=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["YB39loxHH43S4MF8aTOiogcIbBAIq5Qj3dlJkIfYVxI=","t631p0kaOa0gMRIcaPzz1ZVPZ1kuq4pq4kYPWQgoPcM="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
@@ -0,0 +1,17 @@
|
||||
// src/Elecciones.Core/DTOs/ApiRequests/CreateAgrupacionDto.cs
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Elecciones.Core.DTOs.ApiRequests;
|
||||
|
||||
public class CreateAgrupacionDto
|
||||
{
|
||||
[Required(ErrorMessage = "El nombre es obligatorio.")]
|
||||
[MaxLength(255)]
|
||||
public string Nombre { get; set; } = null!;
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? NombreCorto { get; set; }
|
||||
|
||||
[MaxLength(7)] // Formato #RRGGBB
|
||||
public string? Color { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// src/Elecciones.Core/DTOs/ApiRequests/UpdateBancaPreviaDto.cs
|
||||
using Elecciones.Core.Enums;
|
||||
|
||||
namespace Elecciones.Core.DTOs.ApiRequests;
|
||||
|
||||
public class UpdateBancaPreviaDto
|
||||
{
|
||||
public string AgrupacionPoliticaId { get; set; } = null!;
|
||||
public TipoCamara Camara { get; set; }
|
||||
public int Cantidad { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// src/Elecciones.Core/DTOs/ApiResponses/Resumen/PartidoResumenDto.cs
|
||||
namespace Elecciones.Core.DTOs.ApiResponses.Resumen;
|
||||
|
||||
public class PartidoResumenDto
|
||||
{
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public decimal Porcentaje { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// src/Elecciones.Core/DTOs/ApiResponses/Resumen/ProvinciaResumenDto.cs
|
||||
namespace Elecciones.Core.DTOs.ApiResponses.Resumen;
|
||||
|
||||
public class ProvinciaResumenDto
|
||||
{
|
||||
public string ProvinciaId { get; set; } = string.Empty;
|
||||
public string ProvinciaNombre { get; set; } = string.Empty;
|
||||
public decimal PorcentajeEscrutado { get; set; }
|
||||
public List<PartidoResumenDto> Resultados { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/Elecciones.Core/DTOs/ApiResponses/Tablas/ResultadoFilaDto.cs
|
||||
namespace Elecciones.Core.DTOs.ApiResponses.Tablas;
|
||||
|
||||
public class ResultadoFilaDto
|
||||
{
|
||||
public int AmbitoId { get; set; }
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public int Orden { get; set; }
|
||||
public string? Fuerza1Display { get; set; }
|
||||
public decimal Fuerza1Porcentaje { get; set; }
|
||||
public string? Fuerza2Display { get; set; }
|
||||
public decimal Fuerza2Porcentaje { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// src/Elecciones.Core/DTOs/ApiResponses/Tablas/ResultadoSeccionDto.cs
|
||||
namespace Elecciones.Core.DTOs.ApiResponses.Tablas;
|
||||
|
||||
public class ResultadoSeccionDto
|
||||
{
|
||||
public string SeccionId { get; set; } = string.Empty;
|
||||
public string Nombre { get; set; } = string.Empty;
|
||||
public List<ResultadoFilaDto> Municipios { get; set; } = new();
|
||||
}
|
||||
@@ -17,9 +17,21 @@ public class VotosOtrosDto
|
||||
[JsonPropertyName("votosEnBlancoPorcentaje")]
|
||||
public decimal VotosEnBlancoPorcentaje { get; set; }
|
||||
|
||||
[JsonPropertyName("votosRecurridosComandoImpugnados")]
|
||||
[JsonPropertyName("votosRecurridos")]
|
||||
public long VotosRecurridos { get; set; }
|
||||
|
||||
[JsonPropertyName("votosRecurridosComandoImpugnadosPorcentaje")]
|
||||
[JsonPropertyName("votosRecurridosPorcentaje")]
|
||||
public decimal VotosRecurridosPorcentaje { get; set; }
|
||||
|
||||
[JsonPropertyName("votosComando")]
|
||||
public long VotosComando { get; set; }
|
||||
|
||||
[JsonPropertyName("votosComandoPorcentaje")]
|
||||
public decimal VotosComandoPorcentaje { get; set; }
|
||||
|
||||
[JsonPropertyName("votosImpugnados")]
|
||||
public long VotosImpugnados { get; set; }
|
||||
|
||||
[JsonPropertyName("votosImpugnadosPorcentaje")]
|
||||
public decimal VotosImpugnadosPorcentaje { get; set; }
|
||||
}
|
||||
@@ -13,7 +13,7 @@ using System.Reflection;
|
||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Elecciones.Core")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+11d9417ef5e3645d51c0ab227a0804985db4b1fb")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8d7f5c1db6f69f3e6bee29c32b877e64080c45ed")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Elecciones.Core")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Elecciones.Core")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -23,6 +23,8 @@ public class EleccionesDbContext(DbContextOptions<EleccionesDbContext> options)
|
||||
public DbSet<CandidatoOverride> CandidatosOverrides { get; set; }
|
||||
public DbSet<Eleccion> Elecciones { get; set; }
|
||||
public DbSet<BancaPrevia> BancasPrevias { get; set; }
|
||||
public DbSet<Conurbano> Conurbano { get; set; }
|
||||
public IQueryable<AgrupacionNacionalMapping> MapeoAgrupaciones => Set<AgrupacionNacionalMapping>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -30,6 +32,12 @@ public class EleccionesDbContext(DbContextOptions<EleccionesDbContext> options)
|
||||
|
||||
modelBuilder.UseCollation("Modern_Spanish_CI_AS");
|
||||
|
||||
modelBuilder.Entity<AgrupacionNacionalMapping>(entity =>
|
||||
{
|
||||
entity.HasNoKey();
|
||||
entity.ToView("MapeoAgrupacionesNacionales"); // O .ToTable("MapeoAgrupacionesNacionales")
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Eleccion>(entity =>
|
||||
{
|
||||
// Le decimos a EF que proporcionaremos el valor de la clave primaria.
|
||||
@@ -50,14 +58,22 @@ public class EleccionesDbContext(DbContextOptions<EleccionesDbContext> options)
|
||||
entity.Property(e => e.VotosNulosPorcentaje).HasPrecision(18, 4);
|
||||
entity.Property(e => e.VotosEnBlancoPorcentaje).HasPrecision(18, 4);
|
||||
entity.Property(e => e.VotosRecurridosPorcentaje).HasPrecision(18, 4);
|
||||
entity.Property(e => e.VotosComandoPorcentaje).HasPrecision(18, 4);
|
||||
entity.Property(e => e.VotosImpugnadosPorcentaje).HasPrecision(18, 4);
|
||||
});
|
||||
|
||||
// Precisión para el campo de porcentaje en ResultadoVoto
|
||||
modelBuilder.Entity<ResultadoVoto>()
|
||||
.Property(e => e.PorcentajeVotos).HasPrecision(18, 4);
|
||||
|
||||
modelBuilder.Entity<ResumenVoto>()
|
||||
.Property(e => e.VotosPorcentaje).HasPrecision(5, 2);
|
||||
modelBuilder.Entity<ResumenVoto>(entity =>
|
||||
{
|
||||
entity.Property(e => e.VotosPorcentaje).HasPrecision(5, 2);
|
||||
// Esto asegura que no se pueda tener dos entradas para el mismo partido,
|
||||
// en la misma categoría y en el mismo ámbito.
|
||||
entity.HasIndex(r => new { r.AmbitoGeograficoId, r.CategoriaId, r.AgrupacionPoliticaId })
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EstadoRecuentoGeneral>(entity =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// src/Elecciones.Database/Entities/AgrupacionNacionalMapping.cs
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Elecciones.Database.Entities;
|
||||
|
||||
public class AgrupacionNacionalMapping
|
||||
{
|
||||
[Key]
|
||||
public string IdAgrupacionProvincial { get; set; } = null!;
|
||||
public string AgrupacionNacional { get; set; } = null!;
|
||||
public string? NombreCortoNacional { get; set; }
|
||||
public string? ColorNacional { get; set; }
|
||||
public string? LogoUrlNacional { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user