feat(UI): Implement holiday awareness across all data widgets
- All data widgets (tables and cards) now use the useIsHoliday hook. - An informational alert is displayed on holidays, without hiding the last available data. - Unifies loading state logic to wait for both data and holiday status, preventing race conditions. - Ensures a consistent and robust user experience across all components."
This commit is contained in:
		| @@ -1,4 +1,4 @@ | ||||
| import { useState } from 'react'; | ||||
| import React, { useState, useRef } from 'react'; | ||||
| import { | ||||
|     Box, CircularProgress, Alert, Paper, Typography, Dialog, | ||||
|     DialogTitle, DialogContent, IconButton | ||||
| @@ -8,61 +8,63 @@ import ScaleIcon from '@mui/icons-material/Scale'; | ||||
| import { PiChartLineUpBold } from "react-icons/pi"; | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
|  | ||||
| // Importaciones de nuestro proyecto | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||
| import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters'; | ||||
| import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget'; | ||||
| import { HolidayAlert } from './common/HolidayAlert'; | ||||
|  | ||||
| // El subcomponente ahora tendrá un botón para el gráfico. | ||||
| const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => { | ||||
| /** | ||||
|  * Sub-componente para una única tarjeta de categoría de ganado. | ||||
|  */ | ||||
| const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => { | ||||
|     return ( | ||||
|         // Añadimos posición relativa para poder posicionar el botón del gráfico. | ||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}> | ||||
|             <IconButton | ||||
|                 aria-label="ver historial" | ||||
|                 onClick={(e) => { | ||||
|                     e.stopPropagation(); | ||||
|                     onChartClick(); | ||||
|                 }} | ||||
|                 sx={{ | ||||
|                     position: 'absolute', | ||||
|                     top: 8, | ||||
|                     right: 8, | ||||
|                     backgroundColor: 'rgba(255, 255, 255, 0.7)', // Fondo semitransparente | ||||
|                     backdropFilter: 'blur(2px)', // Efecto "frosty glass" para el fondo | ||||
|                     border: '1px solid rgba(0, 0, 0, 0.1)', | ||||
|                     boxShadow: '0 2px 5px rgba(0,0,0,0.1)', | ||||
|                     transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios | ||||
|                     '&:hover': { | ||||
|                         transform: 'translateY(-2px)', // Se eleva un poco | ||||
|                         boxShadow: '0 4px 10px rgba(0,0,0,0.2)', // La sombra se hace más grande | ||||
|                         backgroundColor: 'rgba(255, 255, 255, 0.9)', | ||||
|                     } | ||||
|                 }} | ||||
|             > | ||||
|                 <PiChartLineUpBold size="20" /> | ||||
|             </IconButton> | ||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative', display: 'flex', flexDirection: 'column' }}> | ||||
|             {/* Contenido principal de la tarjeta */} | ||||
|             <Box sx={{ flexGrow: 1 }}> | ||||
|                 <IconButton | ||||
|                     aria-label="ver historial" | ||||
|                     onClick={onChartClick} | ||||
|                     sx={{ | ||||
|                         position: 'absolute', top: 8, right: 8, | ||||
|                         backgroundColor: 'rgba(255, 255, 255, 0.7)', | ||||
|                         backdropFilter: 'blur(2px)', | ||||
|                         border: '1px solid rgba(0, 0, 0, 0.1)', | ||||
|                         boxShadow: '0 2px 5px rgba(0,0,0,0.1)', | ||||
|                         transition: 'all 0.2s ease-in-out', | ||||
|                         '&:hover': { | ||||
|                             transform: 'translateY(-2px)', | ||||
|                             boxShadow: '0 4px 10px rgba(0,0,0,0.2)', | ||||
|                             backgroundColor: 'rgba(255, 255, 255, 0.9)', | ||||
|                         } | ||||
|                     }} | ||||
|                 > | ||||
|                     <PiChartLineUpBold size="20" /> | ||||
|                 </IconButton> | ||||
|  | ||||
|             <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 /* Espacio para el botón */ }}> | ||||
|                 {registro.categoria} | ||||
|                 <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> | ||||
|             </Typography> | ||||
|                 <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 }}> | ||||
|                     {registro.categoria} | ||||
|                     <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> | ||||
|                 </Typography> | ||||
|  | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> | ||||
|                 <Typography variant="body2" color="text.secondary">Precio Mediano:</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> | ||||
|                     <Typography variant="body2" color="text.secondary">Precio Mediano:</Typography> | ||||
|                     <Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|  | ||||
|             {/* Pie de la tarjeta */} | ||||
|             <Box sx={{ mt: 2, pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|             <Box sx={{ mt: 'auto', pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                 <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}> | ||||
|                     <Box sx={{ textAlign: 'center' }}> | ||||
|                         <PiCow size="28" /> | ||||
| @@ -77,69 +79,88 @@ const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onCh | ||||
|                 </Box> | ||||
|  | ||||
|                 <Typography variant="caption" sx={{ display: 'block', textAlign: 'left', color: 'text.secondary', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                     Dato Registrado el {formatDateOnly(registro.fechaRegistro)} | ||||
|                     {formatDateOnly(registro.fechaRegistro)} | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Widget autónomo para las tarjetas de resumen del Mercado Agroganadero. | ||||
|  */ | ||||
| export const MercadoAgroCardWidget = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); | ||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const isHoliday = useIsHoliday('BA'); | ||||
|  | ||||
|     const handleChartClick = (registro: CotizacionGanado) => { | ||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); | ||||
|     const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
|     const handleChartClick = (registro: CotizacionGanado, event: React.MouseEvent<HTMLButtonElement>) => { | ||||
|         triggerButtonRef.current = event.currentTarget; | ||||
|         setSelectedCategory(registro); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseDialog = () => { | ||||
|         setSelectedCategory(null); | ||||
|         setTimeout(() => { | ||||
|             triggerButtonRef.current?.focus(); | ||||
|         }, 0); | ||||
|     }; | ||||
|  | ||||
|     if (loading) { | ||||
|     const isLoading = dataLoading || isHoliday === null; | ||||
|  | ||||
|     if (isLoading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|     } | ||||
|     if (error) { | ||||
|         return <Alert severity="error">{error}</Alert>; | ||||
|  | ||||
|     if (dataError) { | ||||
|         return <Alert severity="error">{dataError}</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!data || data.length === 0) { | ||||
|         if (isHoliday) { | ||||
|             return <HolidayAlert />; | ||||
|         } | ||||
|         return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <> | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> | ||||
|             {isHoliday && ( | ||||
|                 <Box sx={{ mb: 2 }}> | ||||
|                     <HolidayAlert /> | ||||
|                 </Box> | ||||
|             )} | ||||
|  | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: { xs: 2, sm: 2, md: 3 }, justifyContent: 'center' }}> | ||||
|                 {data.map(registro => ( | ||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} /> | ||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={(event) => handleChartClick(registro, event)} /> | ||||
|                 ))} | ||||
|             </Box> | ||||
|  | ||||
|             <Dialog | ||||
|                 open={Boolean(selectedCategory)} | ||||
|                 onClose={handleCloseDialog} | ||||
|                 maxWidth="md" | ||||
|                 fullWidth | ||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera | ||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||
|             > | ||||
|                 <IconButton | ||||
|                     aria-label="close" | ||||
|                     onClick={handleCloseDialog} | ||||
|                     sx={{ | ||||
|                         position: 'absolute', | ||||
|                         top: -15, // Mueve el botón hacia arriba, fuera del Dialog | ||||
|                         right: -15, // Mueve el botón hacia la derecha, fuera del Dialog | ||||
|                         position: 'absolute', top: -15, right: -15, | ||||
|                         color: (theme) => theme.palette.grey[500], | ||||
|                         backgroundColor: 'white', | ||||
|                         boxShadow: 3, // Añade una sombra para que destaque | ||||
|                         '&:hover': { | ||||
|                             backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse | ||||
|                         }, | ||||
|                         backgroundColor: 'white', boxShadow: 3, | ||||
|                         '&:hover': { backgroundColor: 'grey.100' }, | ||||
|                     }} | ||||
|                 > | ||||
|                     <CloseIcon /> | ||||
|                 </IconButton> | ||||
|  | ||||
|                 <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|                     Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||
|                     Historial de 30 días para {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||
|                 </DialogTitle> | ||||
|                 <DialogContent dividers> | ||||
|                     {selectedCategory && ( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user