feat: Add visual summary cards for Agro/Grains and implement 24h time format

This commit is contained in:
2025-07-01 16:05:26 -03:00
parent c4eead1033
commit ab9e77fa81
22 changed files with 908 additions and 125 deletions

View File

@@ -3,6 +3,9 @@ import { BolsaLocalWidget } from './components/BolsaLocalWidget';
import { MercadoAgroWidget } from './components/MercadoAgroWidget';
import { GranosWidget } from './components/GranosWidget';
import { BolsaUsaWidget } from './components/BolsaUsaWidget';
import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
import { GranosCardWidget } from './components/GranosCardWidget';
import { Divider } from '@mui/material';
function App() {
return (
@@ -11,7 +14,7 @@ function App() {
<AppBar position="static" sx={{ backgroundColor: '#028fbe' }}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Mercados Modernos - Demo
Mercados - El Día
</Typography>
</Toolbar>
</AppBar>
@@ -27,16 +30,36 @@ function App() {
<MercadoAgroWidget />
</Box>
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado Agroganadero de Cañuelas</Typography>
<MercadoAgroCardWidget />
<Divider sx={{ my: 3 }}>
<Typography variant="caption" color="text.secondary">Datos Detallados</Typography>
</Divider>
<MercadoAgroWidget />
</Box>
{/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography>
<GranosWidget />
<GranosCardWidget />
<Divider sx={{ my: 3 }}>
<Typography variant="caption" color="text.secondary">Datos Detallados</Typography>
</Divider>
<GranosWidget />
</Box>
{/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography>
<GranosWidget />
</Box>
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
<Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado de Valores de Estados Unidos</Typography>
<BolsaUsaWidget />
<BolsaUsaWidget />
</Box>
{/* --- Sección 4: Mercado de Valores Local --- */}
@@ -47,9 +70,9 @@ function App() {
</Container>
<Box component="footer" sx={{ p: 2, mt: 'auto', backgroundColor: '#f5f5f5', textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
Desarrollado por El Día - {new Date().getFullYear()}
</Typography>
<Typography variant="body2" color="text.secondary">
Desarrollado por El Día - {new Date().getFullYear()}
</Typography>
</Box>
</>
);

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
// Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045).
// En producción, esto debería apuntar a la URL real del servidor donde se despliegue la API.
const API_BASE_URL = 'http://localhost:5045/api';
const API_BASE_URL = 'http://192.168.10.78:5045/api';
const apiClient = axios.create({
baseURL: API_BASE_URL,

View File

@@ -1,38 +1,42 @@
import { useState } from 'react';
import {
Box, CircularProgress, Alert, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
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'; // Para cambios neutros
import RemoveIcon from '@mui/icons-material/Remove';
import { formatFullDateTime } from '../utils/formatters';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
// Función para formatear números
const formatNumber = (num: number) => {
return new Intl.NumberFormat('es-AR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
};
const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
// Componente para mostrar la variación con color e icono
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
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' }}>{formatNumber(value)}%</Typography>
</Box>
);
};
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 handleCloseDialog = () => {
setSelectedTicker(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -47,38 +51,52 @@ export const BolsaLocalWidget = () => {
}
return (
<TableContainer component={Paper}>
<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>
<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>
<>
<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>
))}
</TableBody>
</Table>
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
Fuente: Yahoo Finance
</Typography>
</Tooltip>
</TableContainer>
</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>
<DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" />}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,36 +1,44 @@
import { useState } from 'react';
import {
Box, CircularProgress, Alert, Table, TableBody, TableCell,
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
DialogContent, IconButton
} from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
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 type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget';
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US', { // Usamos formato de EEUU
style: 'currency',
currency: 'USD',
}).format(num);
};
// 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);
const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
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' }}>
{value.toFixed(2)}%
</Typography>
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatPercentage(value)}%</Typography>
</Box>
);
};
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 handleCloseDialog = () => {
setSelectedTicker(null);
};
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -40,43 +48,58 @@ 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 puede estar desactivado)</Alert>;
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU. (El fetcher podría estar desactivado en el Worker).</Alert>;
}
return (
<TableContainer component={Paper}>
<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>
<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>
<>
<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>
))}
</TableBody>
</Table>
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
Fuente: Finnhub
</Typography>
</Tooltip>
</TableContainer>
</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>
<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>
<DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" />}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,102 @@
import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material';
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';
// 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" />;
}
};
// 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;
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' }}>
${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>
);
};
export const GranosCardWidget = () => {
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
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 de granos disponibles.</Alert>;
}
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
{data.map((grano) => (
<GranoCard key={grano.nombre} grano={grano} />
))}
</Box>
);
};

View File

@@ -0,0 +1,45 @@
import { Box, CircularProgress, Alert } from '@mui/material';
import type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface HistoricalChartWidgetProps {
ticker: string;
mercado: 'Local' | 'EEUU';
}
// Formateador para el eje X (muestra DD/MM)
const formatXAxis = (tickItem: string) => {
const date = new Date(tickItem);
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`);
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.</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 - 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} />
</LineChart>
</ResponsiveContainer>
);
};

View File

@@ -0,0 +1,79 @@
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 type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatInteger } from '../utils/formatters';
const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => {
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}
</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>
</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>
</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>
</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>
<Typography variant="caption" color="text.secondary">Cabezas</Typography>
</Box>
<Box sx={{ textAlign: 'center' }}>
<ScaleIcon color="action" />
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.kilosTotales)}</Typography>
<Typography variant="caption" color="text.secondary">Kilos</Typography>
</Box>
</Box>
</Paper>
);
};
// 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');
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>;
}
// 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>
);
};

View File

@@ -53,7 +53,7 @@ export const MercadoAgroWidget = () => {
<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)} Kg</TableCell>
<TableCell align="right">{formatNumber(row.kilosTotales, 0)}</TableCell>
<TableCell align="right">${formatNumber(row.importeTotal)}</TableCell>
</TableRow>
))}

View File

@@ -0,0 +1,38 @@
// Formateadores numéricos
export const formatCurrency = (num: number, currency = 'ARS') => {
const style = currency === 'USD' ? 'currency' : 'decimal';
const locale = currency === 'USD' ? 'en-US' : 'es-AR';
return new Intl.NumberFormat(locale, {
style: style,
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
};
export const formatInteger = (num: number) => {
return new Intl.NumberFormat('es-AR').format(num);
};
// Formateadores de fecha y hora
export const formatFullDateTime = (dateString: string) => {
return new Date(dateString).toLocaleString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS
timeZone: 'America/Argentina/Buenos_Aires',
});
};
export const formatDateOnly = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
timeZone: 'America/Argentina/Buenos_Aires',
});
};