feat: Add visual summary cards for Agro/Grains and implement 24h time format
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
102
frontend/src/components/GranosCardWidget.tsx
Normal file
102
frontend/src/components/GranosCardWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
frontend/src/components/HistoricalChartWidget.tsx
Normal file
45
frontend/src/components/HistoricalChartWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
frontend/src/components/MercadoAgroCardWidget.tsx
Normal file
79
frontend/src/components/MercadoAgroCardWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
38
frontend/src/utils/formatters.ts
Normal file
38
frontend/src/utils/formatters.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user