Feat Widgets Cards y Optimización de Consultas

This commit is contained in:
2025-09-28 19:04:09 -03:00
parent 67634ae947
commit 3b0eee25e6
71 changed files with 5415 additions and 442 deletions

View File

@@ -0,0 +1,64 @@
// src/features/legislativas/nacionales/components/MiniMapaSvg.tsx
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useMemo } from 'react';
import { assetBaseUrl } from '../../../../apiService';
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
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
gcTime: Infinity,
retry: false, // No reintentar si el archivo no existe
});
// 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" />;
}
if (isError || !modifiedSvg) {
// Muestra un placeholder si el SVG no se encontró o está vacío
return <div className="map-placeholder 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"
dangerouslySetInnerHTML={{ __html: modifiedSvg }}
/>
);
};

View File

@@ -0,0 +1,78 @@
// src/features/legislativas/nacionales/components/ProvinciaCard.tsx
import type { ResumenProvincia } from '../../../../types/types';
import { MiniMapaSvg } from './MiniMapaSvg';
import { ImageWithFallback } from '../../../../components/common/ImageWithFallback';
import { assetBaseUrl } from '../../../../apiService';
interface ProvinciaCardProps {
data: ResumenProvincia;
}
const formatNumber = (num: number) => num.toLocaleString('es-AR');
const formatPercent = (num: number) => `${num.toFixed(2).replace('.', ',')}%`;
export const ProvinciaCard = ({ data }: ProvinciaCardProps) => {
// Determinamos el color del ganador para pasárselo al mapa.
// Si no hay ganador, usamos un color gris por defecto.
const colorGanador = data.resultados[0]?.color || '#d1d1d1';
return (
<div className="provincia-card">
<header className="card-header">
<div className="header-info">
<h3 style={{ whiteSpace: 'normal' }}>{data.provinciaNombre}</h3>
<span>DIPUTADOS NACIONALES</span>
</div>
<div className="header-map">
<MiniMapaSvg provinciaNombre={data.provinciaNombre} fillColor={colorGanador} />
</div>
</header>
<div className="card-body">
{data.resultados.map(res => (
<div key={res.agrupacionId} className="candidato-row">
<ImageWithFallback src={res.fotoUrl ?? undefined} fallbackSrc={`${assetBaseUrl}/default-avatar.png`} alt={res.nombreCandidato ?? res.nombreAgrupacion} className="candidato-foto" />
<div className="candidato-data">
{res.nombreCandidato && (
<span className="candidato-nombre">{res.nombreCandidato}</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>
</div>
<div className="candidato-stats">
<span className="stats-percent">{formatPercent(res.porcentaje)}</span>
<span className="stats-votos">{formatNumber(res.votos)} votos</span>
</div>
<div className="stats-bancas">
+{res.bancasObtenidas}
<span>Bancas</span>
</div>
</div>
))}
</div>
<footer className="card-footer">
<div>
<span>Participación</span>
{/* Usamos los datos reales del estado de recuento */}
<strong>{formatPercent(data.estadoRecuento?.participacionPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Mesas escrutadas</span>
<strong>{formatPercent(data.estadoRecuento?.mesasTotalizadasPorcentaje ?? 0)}</strong>
</div>
<div>
<span>Votos totales</span>
{/* Usamos el nuevo campo cantidadVotantes */}
<strong>{formatNumber(data.estadoRecuento?.cantidadVotantes ?? 0)}</strong>
</div>
</footer>
</div>
);
};