feat: adaptación de los proyectos para utilizar .env y comienzo de preparación para despliegue en docker
This commit is contained in:
		
							
								
								
									
										44
									
								
								frontend/src/components/AgroHistoricalChartWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								frontend/src/components/AgroHistoricalChartWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| import { Box, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||
|  | ||||
| interface AgroHistoricalChartWidgetProps { | ||||
|   categoria: string; | ||||
|   especificaciones: string; | ||||
| } | ||||
|  | ||||
| const formatXAxis = (tickItem: string) => { | ||||
|     const date = new Date(tickItem); | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| }; | ||||
|  | ||||
| export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => { | ||||
|     const apiUrl = `/mercados/agroganadero/history?categoria=${encodeURIComponent(categoria)}&especificaciones=${encodeURIComponent(especificaciones)}&dias=30`; | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>(apiUrl); | ||||
|  | ||||
|     if (loading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: 300 }}><CircularProgress /></Box>; | ||||
|     } | ||||
|  | ||||
|     if (error) { | ||||
|         return <Alert severity="error" sx={{ height: 300 }}>{error}</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!data || data.length < 2) { | ||||
|         return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar esta categoría.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <ResponsiveContainer width="100%" height={300}> | ||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||
|                 <CartesianGrid strokeDasharray="3 3" /> | ||||
|                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> | ||||
|                 <YAxis domain={['dataMin - 10', 'dataMax + 10']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']} /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|         </ResponsiveContainer> | ||||
|     ); | ||||
| }; | ||||
| @@ -8,12 +8,11 @@ import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import { formatFullDateTime } from '../utils/formatters'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
|  | ||||
| const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); | ||||
| import { PiChartLineUpBold } from 'react-icons/pi'; | ||||
|  | ||||
| const Variacion = ({ value }: { value: number }) => { | ||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||
| @@ -21,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => { | ||||
|   return ( | ||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatNumber(value)}%</Typography> | ||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -30,13 +29,10 @@ export const BolsaLocalWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|   const [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||
|  | ||||
|   const handleRowClick = (ticker: string) => { | ||||
|     setSelectedTicker(ticker); | ||||
|   }; | ||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); | ||||
|   const handleCloseDialog = () => setSelectedTicker(null); | ||||
|  | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedTicker(null); | ||||
|   }; | ||||
|   const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || []; | ||||
|  | ||||
|   if (loading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
| @@ -47,54 +43,75 @@ export const BolsaLocalWidget = () => { | ||||
|   } | ||||
|  | ||||
|   if (!data || data.length === 0) { | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado local en este momento.</Alert>; | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <TableContainer component={Paper}> | ||||
|         <Box sx={{ p: 1, m: 0 }}> | ||||
|           <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|             Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|         <Table size="small" aria-label="tabla bolsa local"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Símbolo</TableCell> | ||||
|               <TableCell align="right">Precio Actual</TableCell> | ||||
|               <TableCell align="right">Apertura</TableCell> | ||||
|               <TableCell align="right">Cierre Anterior</TableCell> | ||||
|               <TableCell align="center">% Cambio</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {data.map((row) => ( | ||||
|               <TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}> | ||||
|                 <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                 <TableCell align="right">${formatNumber(row.precioActual)}</TableCell> | ||||
|                 <TableCell align="right">${formatNumber(row.apertura)}</TableCell> | ||||
|                 <TableCell align="right">${formatNumber(row.cierreAnterior)}</TableCell> | ||||
|                 <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|  | ||||
|       <Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|           Historial de 30 días para: {selectedTicker} | ||||
|           <IconButton | ||||
|             aria-label="close" | ||||
|             onClick={handleCloseDialog} | ||||
|             sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }} | ||||
|           > | ||||
|             <CloseIcon /> | ||||
|           </IconButton> | ||||
|         </DialogTitle> | ||||
|       {panelPrincipal.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Box sx={{ p: 1, m: 0 }}> | ||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|               Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} | ||||
|             </Typography> | ||||
|           </Box> | ||||
|           <Table size="small" aria-label="panel principal merval"> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Símbolo</TableCell> | ||||
|                 <TableCell align="right">Precio Actual</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|                 <TableCell align="center">% Cambio</TableCell> | ||||
|                 <TableCell align="center">Historial</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {panelPrincipal.map((row) => ( | ||||
|                 <TableRow key={row.ticker} hover> | ||||
|                   <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                   <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>${formatCurrency(row.apertura)}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>${formatCurrency(row.cierreAnterior)}</TableCell> | ||||
|                   <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                   <TableCell align="center"> | ||||
|                     <IconButton | ||||
|                       aria-label={`ver historial de ${row.ticker}`} | ||||
|                       size="small" | ||||
|                       onClick={() => handleRowClick(row.ticker)} | ||||
|                       sx={{ | ||||
|                         boxShadow: '0 1px 3px rgba(0,0,0,0.1)', | ||||
|                         transition: 'all 0.2s ease-in-out', | ||||
|                         '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } | ||||
|                       }} | ||||
|                     > | ||||
|                       <PiChartLineUpBold size="18" /> | ||||
|                     </IconButton> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ))} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|       )} | ||||
|  | ||||
|       <Dialog | ||||
|         open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth | ||||
|         sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||
|       > | ||||
|         <IconButton | ||||
|           aria-label="close" onClick={handleCloseDialog} | ||||
|           sx={{ | ||||
|             position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], | ||||
|             backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, | ||||
|           }} | ||||
|         > | ||||
|           <CloseIcon /> | ||||
|         </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" />} | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />} | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   | ||||
| @@ -8,14 +8,11 @@ import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import { formatFullDateTime } from '../utils/formatters'; | ||||
| import { formatFullDateTime, formatCurrency } from '../utils/formatters'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
|  | ||||
| // Usamos el formato de EEUU para los precios en dólares | ||||
| const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num); | ||||
| const formatPercentage = (num: number) => num.toFixed(2); | ||||
| import { PiChartLineUpBold } from 'react-icons/pi'; | ||||
|  | ||||
| const Variacion = ({ value }: { value: number }) => { | ||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||
| @@ -23,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => { | ||||
|   return ( | ||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatPercentage(value)}%</Typography> | ||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
| @@ -32,13 +29,10 @@ export const BolsaUsaWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|   const [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||
|  | ||||
|   const handleRowClick = (ticker: string) => { | ||||
|     setSelectedTicker(ticker); | ||||
|   }; | ||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); | ||||
|   const handleCloseDialog = () => setSelectedTicker(null); | ||||
|  | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedTicker(null); | ||||
|   }; | ||||
|   const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || []; | ||||
|  | ||||
|   if (loading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
| @@ -48,56 +42,64 @@ export const BolsaUsaWidget = () => { | ||||
|     return <Alert severity="error">{error}</Alert>; | ||||
|   } | ||||
|  | ||||
|   // Recordatorio de que el fetcher puede estar desactivado | ||||
|   if (!data || data.length === 0) { | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado de EEUU. (El fetcher podría estar desactivado en el Worker).</Alert>; | ||||
|     return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <TableContainer component={Paper}> | ||||
|         <Box sx={{ p: 1, pb: 0 }}> | ||||
|           <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|             Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||
|           </Typography> | ||||
|         </Box> | ||||
|         <Table size="small" aria-label="tabla bolsa eeuu"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Símbolo</TableCell> | ||||
|               <TableCell align="right">Precio Actual</TableCell> | ||||
|               <TableCell align="right">Apertura</TableCell> | ||||
|               <TableCell align="right">Cierre Anterior</TableCell> | ||||
|               <TableCell align="center">% Cambio</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {data.map((row) => ( | ||||
|               <TableRow key={row.ticker} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(row.ticker)}> | ||||
|                 <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                 <TableCell align="right">{formatCurrency(row.precioActual)}</TableCell> | ||||
|                 <TableCell align="right">{formatCurrency(row.apertura)}</TableCell> | ||||
|                 <TableCell align="right">{formatCurrency(row.cierreAnterior)}</TableCell> | ||||
|                 <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|       {/* Renderizamos la tabla solo si hay otras acciones */} | ||||
|       {otherStocks.length > 0 && ( | ||||
|         <TableContainer component={Paper}> | ||||
|           <Box sx={{ p: 1, m: 0 }}> | ||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||
|               Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)} | ||||
|             </Typography> | ||||
|           </Box> | ||||
|           <Table size="small" aria-label="panel principal eeuu"> | ||||
|             <TableHead> | ||||
|               <TableRow> | ||||
|                 <TableCell>Símbolo</TableCell> | ||||
|                 <TableCell align="right">Precio Actual</TableCell> | ||||
|                  | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell> | ||||
|                 <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell> | ||||
|  | ||||
|       <Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|           Historial de 30 días para: {selectedTicker} | ||||
|           <IconButton | ||||
|             aria-label="close" | ||||
|             onClick={handleCloseDialog} | ||||
|             sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }} | ||||
|           > | ||||
|             <CloseIcon /> | ||||
|           </IconButton> | ||||
|         </DialogTitle> | ||||
|                 <TableCell align="center">% Cambio</TableCell> | ||||
|                 <TableCell align="center">Historial</TableCell> | ||||
|               </TableRow> | ||||
|             </TableHead> | ||||
|             <TableBody> | ||||
|               {otherStocks.map((row) => ( | ||||
|                 <TableRow key={row.ticker} hover> | ||||
|                   <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell> | ||||
|                   <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                    | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell> | ||||
|                   <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                    | ||||
|                   <TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell> | ||||
|                   <TableCell align="center"> | ||||
|                     <IconButton | ||||
|                       aria-label={`ver historial de ${row.ticker}`} size="small" | ||||
|                       onClick={() => handleRowClick(row.ticker)} | ||||
|                       sx={{ boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'all 0.2s ease-in-out', '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } }} | ||||
|                     ><PiChartLineUpBold size="18" /></IconButton> | ||||
|                   </TableCell> | ||||
|                 </TableRow> | ||||
|               ))} | ||||
|             </TableBody> | ||||
|           </Table> | ||||
|         </TableContainer> | ||||
|       )} | ||||
|  | ||||
|       <Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> | ||||
|         <IconButton aria-label="close" onClick={handleCloseDialog} sx={{ position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, }}> | ||||
|           <CloseIcon /> | ||||
|         </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Historial de 30 días para: {selectedTicker}</DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" />} | ||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />} | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   | ||||
							
								
								
									
										43
									
								
								frontend/src/components/GrainsHistoricalChartWidget.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								frontend/src/components/GrainsHistoricalChartWidget.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| import { Box, CircularProgress, Alert } from '@mui/material'; | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||
|  | ||||
| interface GrainsHistoricalChartWidgetProps { | ||||
|   nombre: string; | ||||
| } | ||||
|  | ||||
| const formatXAxis = (tickItem: string) => { | ||||
|     const date = new Date(tickItem); | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| }; | ||||
|  | ||||
| export const GrainsHistoricalChartWidget = ({ nombre }: GrainsHistoricalChartWidgetProps) => { | ||||
|     const apiUrl = `/mercados/granos/history/${encodeURIComponent(nombre)}?dias=30`; | ||||
|     const { data, loading, error } = useApiData<CotizacionGrano[]>(apiUrl); | ||||
|  | ||||
|     if (loading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: 300 }}><CircularProgress /></Box>; | ||||
|     } | ||||
|  | ||||
|     if (error) { | ||||
|         return <Alert severity="error" sx={{ height: 300 }}>{error}</Alert>; | ||||
|     } | ||||
|  | ||||
|     if (!data || data.length < 2) { | ||||
|         return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar este grano.</Alert>; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|         <ResponsiveContainer width="100%" height={300}> | ||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||
|                 <CartesianGrid strokeDasharray="3 3" /> | ||||
|                 <XAxis dataKey="fechaOperacion" tickFormatter={formatXAxis} /> | ||||
|                 <YAxis domain={['dataMin - 1000', 'dataMax + 1000']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']} /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|         </ResponsiveContainer> | ||||
|     ); | ||||
| }; | ||||
| @@ -1,84 +1,113 @@ | ||||
| import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material'; | ||||
| import React, { useState, useRef } from 'react'; | ||||
| import { | ||||
|   Box, CircularProgress, Alert, Paper, Typography, Dialog, | ||||
|   DialogTitle, DialogContent, IconButton | ||||
| } from '@mui/material'; | ||||
| import { PiChartLineUpBold } from "react-icons/pi"; | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| // Iconos de react-icons para cada grano | ||||
| import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi"; | ||||
| import { TbGrain } from "react-icons/tb"; | ||||
|  | ||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatCurrency, formatDateOnly } from '../utils/formatters'; | ||||
| import { formatInteger, formatDateOnly } from '../utils/formatters'; | ||||
| import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; | ||||
|  | ||||
| // Función para elegir el icono según el nombre del grano | ||||
| const getGrainIcon = (nombre: string) => { | ||||
|     switch (nombre.toLowerCase()) { | ||||
|         case 'girasol': | ||||
|             return <GiSunflower size={28} color="#fbc02d" />; | ||||
|         case 'trigo': | ||||
|             return <GiWheat size={28} color="#fbc02d" />; | ||||
|         case 'sorgo': | ||||
|             return <TbGrain size={28} color="#fbc02d" />; | ||||
|         case 'maiz': | ||||
|             return <GiCorn size={28} color="#fbc02d" />; | ||||
|         default: | ||||
|             return <GiGrain size={28} color="#fbc02d" />; | ||||
|     } | ||||
|   switch (nombre.toLowerCase()) { | ||||
|     case 'girasol': return <GiSunflower size={28} color="#fbc02d" />; | ||||
|     case 'trigo': return <GiWheat size={28} color="#fbc02d" />; | ||||
|     case 'sorgo': return <TbGrain size={28} color="#fbc02d" />; | ||||
|     case 'maiz': return <GiCorn size={28} color="#fbc02d" />; | ||||
|     default: return <GiGrain size={28} color="#fbc02d" />; | ||||
|   } | ||||
| }; | ||||
|  | ||||
| // Subcomponente para una única tarjeta de grano | ||||
| const GranoCard = ({ grano }: { grano: CotizacionGrano }) => { | ||||
|     const isPositive = grano.variacionPrecio > 0; | ||||
|     const isNegative = grano.variacionPrecio < 0; | ||||
|     const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; | ||||
|     const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; | ||||
| const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => { | ||||
|   const isPositive = grano.variacionPrecio > 0; | ||||
|   const isNegative = grano.variacionPrecio < 0; | ||||
|   const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; | ||||
|   const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; | ||||
|   return ( | ||||
|     <Paper | ||||
|       elevation={2} | ||||
|       sx={{ | ||||
|         position: 'relative', | ||||
|         p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', | ||||
|         flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '160px', | ||||
|         borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}` | ||||
|       }} | ||||
|     > | ||||
|       <IconButton | ||||
|         aria-label={`ver historial de ${grano.nombre}`} | ||||
|         onClick={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> | ||||
|       <Box sx={{ display: 'flex', alignItems: 'center', mb: 1, pr: 5 }}> | ||||
|         {getGrainIcon(grano.nombre)} | ||||
|         <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}> | ||||
|             {grano.nombre} | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|     return ( | ||||
|         <Paper  | ||||
|             elevation={2}  | ||||
|             sx={{  | ||||
|                 p: 2,  | ||||
|                 display: 'flex',  | ||||
|                 flexDirection: 'column',  | ||||
|                 justifyContent: 'space-between',  | ||||
|                 flex: '1 1 180px', | ||||
|                 minWidth: '180px', | ||||
|                 maxWidth: '220px', | ||||
|                 height: '160px', | ||||
|                 borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}` | ||||
|             }} | ||||
|         > | ||||
|             <Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}> | ||||
|                 {getGrainIcon(grano.nombre)} | ||||
|                 <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}> | ||||
|                     {grano.nombre} | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|       <Box sx={{ textAlign: 'center', my: 1 }}> | ||||
|         <Typography variant="h5" component="p" sx={{ fontWeight: 'bold' }}> | ||||
|           ${formatInteger(grano.precio)} | ||||
|         </Typography> | ||||
|         <Typography variant="caption" color="text.secondary"> | ||||
|           por Tonelada | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|             <Box sx={{ textAlign: 'center', my: 1 }}> | ||||
|                 <Typography variant="h5" component="p" sx={{ fontWeight: 'bold' }}> | ||||
|                     ${formatCurrency(grano.precio)} | ||||
|                 </Typography> | ||||
|                 <Typography variant="caption" color="text.secondary"> | ||||
|                     por Tonelada | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||
|                 <Icon sx={{ fontSize: '1.1rem', mr: 0.5 }} /> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}> | ||||
|                     {formatCurrency(grano.variacionPrecio)} | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|             <Typography variant="caption" align="center" sx={{ mt: 1, color: 'text.secondary' }}> | ||||
|                 Operación: {formatDateOnly(grano.fechaOperacion)} | ||||
|             </Typography> | ||||
|         </Paper> | ||||
|     ); | ||||
|       <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||
|         <Icon sx={{ fontSize: '1.1rem', mr: 0.5 }} /> | ||||
|         <Typography variant="body2" sx={{ fontWeight: 'bold' }}> | ||||
|           {formatInteger(grano.variacionPrecio)} | ||||
|         </Typography> | ||||
|       </Box> | ||||
|       <Typography variant="caption" align="center" sx={{ mt: 1, color: 'text.secondary' }}> | ||||
|         Operación: {formatDateOnly(grano.fechaOperacion)} | ||||
|       </Typography> | ||||
|     </Paper> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const GranosCardWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||
|   const [selectedGrano, setSelectedGrano] = useState<string | null>(null); | ||||
|   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||
|  | ||||
|   const handleChartClick = (nombreGrano: string, event: React.MouseEvent<HTMLButtonElement>) => { | ||||
|     triggerButtonRef.current = event.currentTarget; | ||||
|     setSelectedGrano(nombreGrano); | ||||
|   }; | ||||
|  | ||||
|   const handleCloseDialog = () => { | ||||
|     setSelectedGrano(null); | ||||
|     setTimeout(() => { | ||||
|         triggerButtonRef.current?.focus(); | ||||
|     }, 0); | ||||
|   }; | ||||
|  | ||||
|   if (loading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
| @@ -93,10 +122,47 @@ export const GranosCardWidget = () => { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> | ||||
|     <> | ||||
|     <Box | ||||
|       sx={{ | ||||
|         display: 'flex', | ||||
|         flexWrap: 'wrap', | ||||
|         // Usamos el objeto para definir gaps responsivos | ||||
|         gap: { | ||||
|           xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical) | ||||
|           sm: 4, // 16px en pantallas pequeñas | ||||
|           md: 3, // 24px en pantallas medianas (afecta el espaciado horizontal) | ||||
|         }, | ||||
|         justifyContent: 'center' | ||||
|       }} | ||||
|     > | ||||
|       {data.map((grano) => ( | ||||
|         <GranoCard key={grano.nombre} grano={grano} /> | ||||
|       ))} | ||||
|     </Box> | ||||
|           <GranoCard key={grano.nombre} grano={grano} onChartClick={(event) => handleChartClick(grano.nombre, event)} /> | ||||
|         ))} | ||||
|       </Box> | ||||
|       <Dialog open={Boolean(selectedGrano)} onClose={handleCloseDialog} maxWidth="md" fullWidth 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 | ||||
|                         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 | ||||
|                         }, | ||||
|                     }} | ||||
|                 > | ||||
|                     <CloseIcon /> | ||||
|                 </IconButton> | ||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Mensual de {selectedGrano}</DialogTitle> | ||||
|         <DialogContent dividers> | ||||
|           {selectedGrano && <GrainsHistoricalChartWidget nombre={selectedGrano} />} | ||||
|         </DialogContent> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @@ -10,7 +10,7 @@ import RemoveIcon from '@mui/icons-material/Remove'; | ||||
|  | ||||
| const formatNumber = (num: number) => { | ||||
|   return new Intl.NumberFormat('es-AR', { | ||||
|     minimumFractionDigits: 2, | ||||
|     minimumFractionDigits: 0, | ||||
|     maximumFractionDigits: 2, | ||||
|   }).format(num); | ||||
| }; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi | ||||
| interface HistoricalChartWidgetProps { | ||||
|     ticker: string; | ||||
|     mercado: 'Local' | 'EEUU'; | ||||
|     dias: number; | ||||
| } | ||||
|  | ||||
| // Formateador para el eje X (muestra DD/MM) | ||||
| @@ -14,9 +15,9 @@ const formatXAxis = (tickItem: string) => { | ||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||
| }; | ||||
|  | ||||
| export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidgetProps) => { | ||||
|     // Usamos el hook para obtener los datos del historial de los últimos 30 días | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>(`/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=30`); | ||||
| export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => { | ||||
|     const apiUrl = `/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=${dias}`; | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>(apiUrl); | ||||
|  | ||||
|     if (loading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: 300 }}><CircularProgress /></Box>; | ||||
| @@ -38,7 +39,7 @@ export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidget | ||||
|                 <YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> | ||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} /> | ||||
|                 <Legend /> | ||||
|                 <Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#8884d8" strokeWidth={2} dot={false} /> | ||||
|                 <Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||
|             </LineChart> | ||||
|         </ResponsiveContainer> | ||||
|     ); | ||||
|   | ||||
| @@ -1,39 +1,75 @@ | ||||
| import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material'; | ||||
| import { PiCow } from "react-icons/pi"; // Un icono divertido para "cabezas" | ||||
| import ScaleIcon from '@mui/icons-material/Scale'; // Para kilos | ||||
| import { useState } from 'react'; | ||||
| import { | ||||
|     Box, CircularProgress, Alert, Paper, Typography, Dialog, | ||||
|     DialogTitle, DialogContent, IconButton | ||||
| } from '@mui/material'; | ||||
| import { PiCow } from "react-icons/pi"; | ||||
| import ScaleIcon from '@mui/icons-material/Scale'; | ||||
| import { PiChartLineUpBold } from "react-icons/pi"; | ||||
| import CloseIcon from '@mui/icons-material/Close'; | ||||
|  | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatCurrency, formatInteger } from '../utils/formatters'; | ||||
| import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget'; | ||||
|  | ||||
| const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => { | ||||
| // El subcomponente ahora tendrá un botón para el gráfico. | ||||
| const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => { | ||||
|     return ( | ||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px' }}> | ||||
|             <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2 }}> | ||||
|                 {categoria.categoria} | ||||
|         // 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> | ||||
|  | ||||
|             <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> | ||||
|  | ||||
|             <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(categoria.maximo)}</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(categoria.minimo)}</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(categoria.mediano)}</Typography> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography> | ||||
|             </Box> | ||||
|              | ||||
|  | ||||
|             <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}> | ||||
|                 <Box sx={{ textAlign: 'center' }}> | ||||
|                     <PiCow size={28}/> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.cabezas)}</Typography> | ||||
|                     <PiCow size="28" /> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">Cabezas</Typography> | ||||
|                 </Box> | ||||
|                  <Box sx={{ textAlign: 'center' }}> | ||||
|                 <Box sx={{ textAlign: 'center' }}> | ||||
|                     <ScaleIcon color="action" /> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.kilosTotales)}</Typography> | ||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography> | ||||
|                     <Typography variant="caption" color="text.secondary">Kilos</Typography> | ||||
|                 </Box> | ||||
|             </Box> | ||||
| @@ -41,9 +77,17 @@ const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => { | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| // Este widget agrupa los datos por categoría para un resumen más limpio. | ||||
| export const MercadoAgroCardWidget = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); | ||||
|  | ||||
|     const handleChartClick = (registro: CotizacionGanado) => { | ||||
|         setSelectedCategory(registro); | ||||
|     }; | ||||
|  | ||||
|     const handleCloseDialog = () => { | ||||
|         setSelectedCategory(null); | ||||
|     }; | ||||
|  | ||||
|     if (loading) { | ||||
|         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
| @@ -55,25 +99,50 @@ export const MercadoAgroCardWidget = () => { | ||||
|         return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||
|     } | ||||
|  | ||||
|     // Agrupamos y sumamos los datos por categoría principal | ||||
|     const resumenPorCategoria = data.reduce((acc, item) => { | ||||
|         if (!acc[item.categoria]) { | ||||
|             acc[item.categoria] = { ...item }; | ||||
|         } else { | ||||
|             acc[item.categoria].cabezas += item.cabezas; | ||||
|             acc[item.categoria].kilosTotales += item.kilosTotales; | ||||
|             acc[item.categoria].importeTotal += item.importeTotal; | ||||
|             acc[item.categoria].maximo = Math.max(acc[item.categoria].maximo, item.maximo); | ||||
|             acc[item.categoria].minimo = Math.min(acc[item.categoria].minimo, item.minimo); | ||||
|         } | ||||
|         return acc; | ||||
|     }, {} as Record<string, CotizacionGanado>); | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> | ||||
|             {Object.values(resumenPorCategoria).map(categoria => ( | ||||
|                 <AgroCard key={categoria.categoria} categoria={categoria} /> | ||||
|             ))} | ||||
|         </Box> | ||||
|         <> | ||||
|             <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> | ||||
|                 {data.map(registro => ( | ||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} /> | ||||
|                 ))} | ||||
|             </Box> | ||||
|             <Dialog | ||||
|                 open={Boolean(selectedCategory)} | ||||
|                 onClose={handleCloseDialog} | ||||
|                 maxWidth="md" | ||||
|                 fullWidth | ||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera | ||||
|             > | ||||
|                 <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 | ||||
|                         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 | ||||
|                         }, | ||||
|                     }} | ||||
|                 > | ||||
|                     <CloseIcon /> | ||||
|                 </IconButton> | ||||
|  | ||||
|                 <DialogTitle sx={{ m: 0, p: 2 }}> | ||||
|                     Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||
|                 </DialogTitle> | ||||
|                 <DialogContent dividers> | ||||
|                     {selectedCategory && ( | ||||
|                         <AgroHistoricalChartWidget | ||||
|                             categoria={selectedCategory.categoria} | ||||
|                             especificaciones={selectedCategory.especificaciones} | ||||
|                         /> | ||||
|                     )} | ||||
|                 </DialogContent> | ||||
|             </Dialog> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @@ -4,66 +4,114 @@ import { | ||||
| } from '@mui/material'; | ||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters'; | ||||
|  | ||||
| const formatNumber = (num: number, fractionDigits = 2) => { | ||||
|   return new Intl.NumberFormat('es-AR', { | ||||
|     minimumFractionDigits: fractionDigits, | ||||
|     maximumFractionDigits: fractionDigits, | ||||
|   }).format(num); | ||||
| // --- V INICIO DE LA MODIFICACIÓN V --- | ||||
| // El sub-componente ahora solo necesita renderizar la tarjeta de móvil. | ||||
| // La fila de la tabla la haremos directamente en el componente principal. | ||||
| const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | ||||
|     const commonStyles = { | ||||
|         cell: { | ||||
|             display: 'flex', justifyContent: 'space-between', py: 0.5, px: 1, | ||||
|             borderBottom: '1px solid rgba(224, 224, 224, 1)', | ||||
|         }, | ||||
|         label: { fontWeight: 'bold', color: 'text.secondary' }, | ||||
|         value: { textAlign: 'right' } | ||||
|     }; | ||||
|      | ||||
|     return ( | ||||
|         <Paper sx={{ mb: 2, p: 1 }}> | ||||
|             <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Categoría:</Typography> | ||||
|                 <Typography variant="body2" sx={{...commonStyles.value, fontWeight: 'bold'}}>{row.categoria}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Especificaciones:</Typography> | ||||
|                 <Typography variant="body2" sx={commonStyles.value}>{row.especificaciones}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Máximo:</Typography> | ||||
|                 <Typography variant="body2" sx={{...commonStyles.value, color: 'success.main'}}>${formatCurrency(row.maximo)}</Typography> | ||||
|             </Box> | ||||
|              <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Mínimo:</Typography> | ||||
|                 <Typography variant="body2" sx={{...commonStyles.value, color: 'error.main'}}>${formatCurrency(row.minimo)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Mediano:</Typography> | ||||
|                 <Typography variant="body2" sx={commonStyles.value}>${formatCurrency(row.mediano)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Cabezas:</Typography> | ||||
|                 <Typography variant="body2" sx={commonStyles.value}>{formatInteger(row.cabezas)}</Typography> | ||||
|             </Box> | ||||
|              <Box sx={commonStyles.cell}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Kg Total:</Typography> | ||||
|                 <Typography variant="body2" sx={commonStyles.value}>{formatInteger(row.kilosTotales)}</Typography> | ||||
|             </Box> | ||||
|             <Box sx={{...commonStyles.cell, borderBottom: 'none'}}> | ||||
|                 <Typography variant="body2" sx={commonStyles.label}>Importe Total:</Typography> | ||||
|                 <Typography variant="body2" sx={commonStyles.value}>${formatInteger(row.importeTotal)}</Typography> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
| // --- ^ FIN DE LA MODIFICACIÓN ^ --- | ||||
|  | ||||
|  | ||||
| export const MercadoAgroWidget = () => { | ||||
|   const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|  | ||||
|   if (loading) { | ||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||
|   } | ||||
|  | ||||
|   if (error) { | ||||
|     return <Alert severity="error">{error}</Alert>; | ||||
|   } | ||||
|  | ||||
|   if (!data || data.length === 0) { | ||||
|     return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||
|   } | ||||
|   if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|   if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|   if (!data || data.length === 0) { return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; } | ||||
|  | ||||
|   return ( | ||||
|     <TableContainer component={Paper}> | ||||
|       <Table size="small" aria-label="tabla mercado agroganadero"> | ||||
|         <TableHead> | ||||
|           <TableRow> | ||||
|             <TableCell>Categoría</TableCell> | ||||
|             <TableCell>Especificaciones</TableCell> | ||||
|             <TableCell align="right">Máximo</TableCell> | ||||
|             <TableCell align="right">Mínimo</TableCell> | ||||
|             <TableCell align="right">Mediano</TableCell> | ||||
|             <TableCell align="right">Cabezas</TableCell> | ||||
|             <TableCell align="right">Kilos Totales</TableCell> | ||||
|             <TableCell align="right">Importe Total</TableCell> | ||||
|           </TableRow> | ||||
|         </TableHead> | ||||
|         <TableBody> | ||||
|           {data.map((row) => ( | ||||
|             <TableRow key={row.id} hover> | ||||
|               <TableCell> | ||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.categoria}</Typography> | ||||
|               </TableCell> | ||||
|               <TableCell>{row.especificaciones}</TableCell> | ||||
|               <TableCell align="right">${formatNumber(row.maximo)}</TableCell> | ||||
|               <TableCell align="right">${formatNumber(row.minimo)}</TableCell> | ||||
|               <TableCell align="right">${formatNumber(row.mediano)}</TableCell> | ||||
|               <TableCell align="right">{formatNumber(row.cabezas, 0)}</TableCell> | ||||
|               <TableCell align="right">{formatNumber(row.kilosTotales, 0)}</TableCell> | ||||
|               <TableCell align="right">${formatNumber(row.importeTotal)}</TableCell> | ||||
|     <Box>  | ||||
|       {/* VISTA DE ESCRITORIO (se oculta en móvil) */} | ||||
|       <TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}> | ||||
|         <Table size="small" aria-label="tabla mercado agroganadero"> | ||||
|           <TableHead> | ||||
|             <TableRow> | ||||
|               <TableCell>Categoría</TableCell> | ||||
|               <TableCell>Especificaciones</TableCell> | ||||
|               <TableCell align="right">Máximo</TableCell> | ||||
|               <TableCell align="right">Mínimo</TableCell> | ||||
|               <TableCell align="right">Mediano</TableCell> | ||||
|               <TableCell align="right">Cabezas</TableCell> | ||||
|               <TableCell align="right">Kg Total</TableCell> | ||||
|               <TableCell align="right">Importe Total</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {data.map((row) => ( | ||||
|               <TableRow key={row.id} hover> | ||||
|                 <TableCell><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.categoria}</Typography></TableCell> | ||||
|                 <TableCell>{row.especificaciones}</TableCell> | ||||
|                 <TableCell align="right">${formatCurrency(row.maximo)}</TableCell> | ||||
|                 <TableCell align="right">${formatCurrency(row.minimo)}</TableCell> | ||||
|                 <TableCell align="right">${formatCurrency(row.mediano)}</TableCell> | ||||
|                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> | ||||
|                 <TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell> | ||||
|                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> | ||||
|               </TableRow> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|        | ||||
|       {/* VISTA DE MÓVIL (se oculta en escritorio) */} | ||||
|       <Box sx={{ display: { xs: 'block', md: 'none' } }}> | ||||
|           {data.map((row) => ( | ||||
|               <AgroDataCard key={row.id} row={row} /> | ||||
|           ))} | ||||
|         </TableBody> | ||||
|       </Table> | ||||
|       <Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> | ||||
|       </Box> | ||||
|  | ||||
|       <Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}> | ||||
|         <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> | ||||
|           Fuente: Mercado Agroganadero S.A. | ||||
|         </Typography> | ||||
|       </Tooltip> | ||||
|     </TableContainer> | ||||
|     </Box> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										77
									
								
								frontend/src/components/MervalHeroCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								frontend/src/components/MervalHeroCard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| import { useState } from 'react'; | ||||
| import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
|  | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { formatInteger, formatCurrency } from '../utils/formatters'; // <-- CORREGIDO: necesitamos formatCurrency | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
|  | ||||
| // --- V SUB-COMPONENTE AÑADIDO V --- | ||||
| const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => { | ||||
|     if (anterior === 0) return null; // Evitar división por cero | ||||
|     const variacionPuntos = actual - anterior; | ||||
|     const variacionPorcentaje = (variacionPuntos / anterior) * 100; | ||||
|  | ||||
|     const isPositive = variacionPuntos > 0; | ||||
|     const isNegative = variacionPuntos < 0; | ||||
|     const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; | ||||
|     const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, color }}> | ||||
|             <Icon sx={{ fontSize: '2rem' }} /> | ||||
|             <Box> | ||||
|                 <Typography component="span" sx={{ fontWeight: 'bold', fontSize: '1.2rem', display: 'block' }}> | ||||
|                     {formatCurrency(variacionPuntos)} | ||||
|                 </Typography> | ||||
|                 <Typography component="span" sx={{ fontWeight: 'bold', fontSize: '1.2rem' }}> | ||||
|                     ({variacionPorcentaje.toFixed(2)}%) | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
| // --- ^ SUB-COMPONENTE AÑADIDO ^ --- | ||||
|  | ||||
| export const MervalHeroCard = () => { | ||||
|     const { data: allLocalData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|     const [dias, setDias] = useState<number>(30); | ||||
|  | ||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||
|     }; | ||||
|      | ||||
|     const mervalData = allLocalData?.find(d => d.ticker === '^MERV'); | ||||
|  | ||||
|     if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|     if (!mervalData) { return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>; } | ||||
|  | ||||
|     return ( | ||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                 <Box> | ||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography> | ||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(mervalData.precioActual)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ pt: 2 }}> | ||||
|                     {/* Ahora sí encontrará el componente */} | ||||
|                     <VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} /> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|             <Box sx={{ mt: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                         <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                     </ToggleButtonGroup> | ||||
|                 </Box> | ||||
|                 <HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} /> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										73
									
								
								frontend/src/components/UsaIndexHeroCard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/components/UsaIndexHeroCard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import { useState } from 'react'; | ||||
| import { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material'; | ||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||
| import RemoveIcon from '@mui/icons-material/Remove'; | ||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||
| import { useApiData } from '../hooks/useApiData'; | ||||
| import { formatInteger } from '../utils/formatters'; | ||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||
|  | ||||
| const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => { | ||||
|     if (anterior === 0) return null; | ||||
|     const variacionPuntos = actual - anterior; | ||||
|     const variacionPorcentaje = (variacionPuntos / anterior) * 100; | ||||
|  | ||||
|     const isPositive = variacionPuntos > 0; | ||||
|     const isNegative = variacionPuntos < 0; | ||||
|     const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; | ||||
|     const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; | ||||
|  | ||||
|     return ( | ||||
|         <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, color }}> | ||||
|             <Icon sx={{ fontSize: '2rem' }} /> | ||||
|             <Box> | ||||
|                 <Typography component="span" sx={{ fontWeight: 'bold', fontSize: '1.2rem', display: 'block' }}> | ||||
|                     {variacionPuntos.toFixed(2)} | ||||
|                 </Typography> | ||||
|                 <Typography component="span" sx={{ fontWeight: 'bold', fontSize: '1.2rem' }}> | ||||
|                     ({variacionPorcentaje.toFixed(2)}%) | ||||
|                 </Typography> | ||||
|             </Box> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const UsaIndexHeroCard = () => { | ||||
|     const { data: allUsaData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|     const [dias, setDias] = useState<number>(30); | ||||
|  | ||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||
|     }; | ||||
|      | ||||
|     const indexData = allUsaData?.find(d => d.ticker === '^GSPC'); | ||||
|  | ||||
|     if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } | ||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } | ||||
|     if (!indexData) { return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>; } | ||||
|  | ||||
|     return ( | ||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> | ||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> | ||||
|                 <Box> | ||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography> | ||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography> | ||||
|                 </Box> | ||||
|                 <Box sx={{ pt: 2 }}> | ||||
|                     <VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} /> | ||||
|                 </Box> | ||||
|             </Box> | ||||
|             <Box sx={{ mt: 2 }}> | ||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> | ||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> | ||||
|                         <ToggleButton value={365}>Anual</ToggleButton> | ||||
|                     </ToggleButtonGroup> | ||||
|                 </Box> | ||||
|                 <HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} /> | ||||
|             </Box> | ||||
|         </Paper> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										85
									
								
								frontend/src/components/raw-data/RawAgroTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								frontend/src/components/raw-data/RawAgroTable.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import type { CotizacionGanado } from '../../models/mercadoModels'; | ||||
| import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| const toCSV = (headers: string[], data: CotizacionGanado[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
|         [ | ||||
|             row.categoria, | ||||
|             row.especificaciones, | ||||
|             formatCurrency(row.maximo), | ||||
|             formatCurrency(row.minimo), | ||||
|             formatCurrency(row.mediano), | ||||
|             formatInteger(row.cabezas), | ||||
|             formatInteger(row.kilosTotales), | ||||
|             formatInteger(row.importeTotal) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| export const RawAgroTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) | ||||
|             .catch(err => { | ||||
|                 console.error('Error al copiar:', err); | ||||
|                 alert('Error: No se pudo copiar la tabla.'); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>Categoría</TableCell> | ||||
|                             <TableCell>Especificaciones</TableCell> | ||||
|                             <TableCell align="right">Máximo</TableCell> | ||||
|                             <TableCell align="right">Mínimo</TableCell> | ||||
|                             <TableCell align="right">Mediano</TableCell> | ||||
|                             <TableCell align="right">Cabezas</TableCell> | ||||
|                             <TableCell align="right">Kg Total</TableCell> | ||||
|                             <TableCell align="right">Importe Total</TableCell> | ||||
|                             <TableCell>Última Act.</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {data.map(row => ( | ||||
|                             <TableRow key={row.id}> | ||||
|                                 <TableCell>{row.categoria}</TableCell> | ||||
|                                 <TableCell>{row.especificaciones}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.maximo)}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.minimo)}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.mediano)}</TableCell> | ||||
|                                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> | ||||
|                                 <TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell> | ||||
|                                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> | ||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> | ||||
|                             </TableRow> | ||||
|                         ))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										78
									
								
								frontend/src/components/raw-data/RawBolsaLocalTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								frontend/src/components/raw-data/RawBolsaLocalTable.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
|         [ | ||||
|             row.ticker, | ||||
|             row.nombreEmpresa, | ||||
|             formatCurrency(row.precioActual), | ||||
|             formatCurrency(row.cierreAnterior), | ||||
|             `${row.porcentajeCambio.toFixed(2)}%` | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| export const RawBolsaLocalTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
|             .then(() => { | ||||
|                 alert('¡Tabla copiada al portapapeles!'); | ||||
|             }) | ||||
|             .catch(err => { | ||||
|                 console.error('Error al copiar:', err); | ||||
|                 alert('Error: No se pudo copiar la tabla.'); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>Ticker</TableCell> | ||||
|                             <TableCell>Nombre</TableCell> | ||||
|                             <TableCell align="right">Último Precio</TableCell> | ||||
|                             <TableCell align="right">Cierre Anterior</TableCell> | ||||
|                             <TableCell align="right">Variación %</TableCell> | ||||
|                             <TableCell>Última Act.</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {data.map(row => ( | ||||
|                             <TableRow key={row.id}> | ||||
|                                 <TableCell>{row.ticker}</TableCell> | ||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior)}</TableCell> | ||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> | ||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> | ||||
|                             </TableRow> | ||||
|                         ))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										76
									
								
								frontend/src/components/raw-data/RawBolsaUsaTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								frontend/src/components/raw-data/RawBolsaUsaTable.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
|         [ | ||||
|             row.ticker, | ||||
|             row.nombreEmpresa, | ||||
|             formatCurrency(row.precioActual, 'USD'), | ||||
|             formatCurrency(row.cierreAnterior, 'USD'), | ||||
|             `${row.porcentajeCambio.toFixed(2)}%` | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| export const RawBolsaUsaTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %"]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) | ||||
|             .catch(err => { | ||||
|                 console.error('Error al copiar:', err); | ||||
|                 alert('Error: No se pudo copiar la tabla.'); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles (el fetcher puede estar desactivado).</Alert>; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>Ticker</TableCell> | ||||
|                             <TableCell>Nombre</TableCell> | ||||
|                             <TableCell align="right">Último Precio</TableCell> | ||||
|                             <TableCell align="right">Cierre Anterior</TableCell> | ||||
|                             <TableCell align="right">Variación %</TableCell> | ||||
|                             <TableCell>Última Act.</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {data.map(row => ( | ||||
|                             <TableRow key={row.id}> | ||||
|                                 <TableCell>{row.ticker}</TableCell> | ||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.precioActual, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior, 'USD')}</TableCell> | ||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> | ||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> | ||||
|                             </TableRow> | ||||
|                         ))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										73
									
								
								frontend/src/components/raw-data/RawGranosTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								frontend/src/components/raw-data/RawGranosTable.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | ||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||
| import { useApiData } from '../../hooks/useApiData'; | ||||
| import type { CotizacionGrano } from '../../models/mercadoModels'; | ||||
| import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters'; | ||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||
|  | ||||
| // Función para convertir datos a formato CSV | ||||
| const toCSV = (headers: string[], data: CotizacionGrano[]) => { | ||||
|     const headerRow = headers.join(';'); | ||||
|     const dataRows = data.map(row =>  | ||||
|         [ | ||||
|             row.nombre, | ||||
|             formatInteger(row.precio), | ||||
|             formatInteger(row.variacionPrecio), | ||||
|             formatDateOnly(row.fechaOperacion) | ||||
|         ].join(';') | ||||
|     ); | ||||
|     return [headerRow, ...dataRows].join('\n'); | ||||
| }; | ||||
|  | ||||
| export const RawGranosTable = () => { | ||||
|     const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||
|  | ||||
|     const handleCopy = () => { | ||||
|         if (!data) return; | ||||
|         const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."]; | ||||
|         const csvData = toCSV(headers, data); | ||||
|          | ||||
|         copyToClipboard(csvData) | ||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) | ||||
|             .catch(err => { | ||||
|                 console.error('Error al copiar:', err); | ||||
|                 alert('Error: No se pudo copiar la tabla.'); | ||||
|             }); | ||||
|     }; | ||||
|  | ||||
|     if (loading) return <CircularProgress />; | ||||
|     if (error) return <Alert severity="error">{error}</Alert>; | ||||
|     if (!data) return null; | ||||
|  | ||||
|     return ( | ||||
|         <Box> | ||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> | ||||
|                 Copiar como CSV | ||||
|             </Button> | ||||
|             <TableContainer component={Paper}> | ||||
|                 <Table size="small"> | ||||
|                     <TableHead> | ||||
|                         <TableRow> | ||||
|                             <TableCell>Grano</TableCell> | ||||
|                             <TableCell align="right">Precio ($/Tn)</TableCell> | ||||
|                             <TableCell align="right">Variación</TableCell> | ||||
|                             <TableCell>Fecha Op.</TableCell> | ||||
|                             <TableCell>Última Act.</TableCell> | ||||
|                         </TableRow> | ||||
|                     </TableHead> | ||||
|                     <TableBody> | ||||
|                         {data.map(row => ( | ||||
|                             <TableRow key={row.id}> | ||||
|                                 <TableCell>{row.nombre}</TableCell> | ||||
|                                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> | ||||
|                                 <TableCell align="right">{formatInteger(row.variacionPrecio)}</TableCell> | ||||
|                                 <TableCell>{formatDateOnly(row.fechaOperacion)}</TableCell> | ||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> | ||||
|                             </TableRow> | ||||
|                         ))} | ||||
|                     </TableBody> | ||||
|                 </Table> | ||||
|             </TableContainer> | ||||
|         </Box> | ||||
|     ); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user