feat: adaptación de los proyectos para utilizar .env y comienzo de preparación para despliegue en docker
This commit is contained in:
@@ -1,13 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/eldia.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mercadors - El Día</title>
|
||||
<title>Mercados Widgets - Catálogo de Componentes</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; background-color: #f4f6f8; padding: 1rem; }
|
||||
h1, h2, h3 { color: #333; }
|
||||
hr { border: none; border-top: 1px solid #e0e0e0; margin: 2rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<h1>Catálogo Completo de Widgets de Mercados</h1>
|
||||
<p>Esta página muestra todos los componentes disponibles para ser integrados.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Sección: Mercado Local</h2>
|
||||
<h3>Componente: Tarjeta de Héroe (Merval)</h3>
|
||||
<div data-mercado-widget="merval-heroe"></div>
|
||||
<br>
|
||||
<h3>Componente: Tabla de Acciones Líderes</h3>
|
||||
<div data-mercado-widget="bolsa-local-tabla"></div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Sección: Mercado de EEUU</h2>
|
||||
<h3>Componente: Tarjeta de Héroe (S&P 500)</h3>
|
||||
<div data-mercado-widget="sp500-heroe"></div>
|
||||
<br>
|
||||
<h3>Componente: Tabla de Acciones de EEUU</h3>
|
||||
<div data-mercado-widget="bolsa-eeuu-tabla"></div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Sección: Granos</h2>
|
||||
<h3>Componente: Tarjetas de Resumen</h3>
|
||||
<div data-mercado-widget="granos-tarjetas"></div>
|
||||
<br>
|
||||
<h3>Componente: Tabla Detallada</h3>
|
||||
<div data-mercado-widget="granos-tabla"></div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Sección: Mercado Agroganadero</h2>
|
||||
<h3>Componente: Tarjetas de Resumen</h3>
|
||||
<div data-mercado-widget="mercado-agro-tarjetas"></div>
|
||||
<br>
|
||||
<h3>Componente: Tabla/Lista Responsiva Detallada</h3>
|
||||
<div data-mercado-widget="mercado-agro-tabla"></div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h2>Sección: Página para Redacción (embebida)</h2>
|
||||
<div data-mercado-widget="pagina-datos-crudos"></div>
|
||||
|
||||
|
||||
<!-- Carga el script principal de nuestra aplicación -->
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
72
frontend/package-lock.json
generated
72
frontend/package-lock.json
generated
@@ -17,10 +17,12 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.3",
|
||||
"recharts": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
@@ -1901,6 +1903,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
|
||||
"integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@@ -2522,6 +2534,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@@ -4090,6 +4111,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz",
|
||||
"integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.6.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz",
|
||||
"integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -4274,6 +4333,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -4487,6 +4552,13 @@
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
|
||||
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.6.3",
|
||||
"recharts": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
|
||||
@@ -24,11 +24,11 @@ function App() {
|
||||
Datos del Mercado
|
||||
</Typography>
|
||||
|
||||
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
|
||||
{/* --- Sección 1: Mercado Agroganadero de Cañuelas ---
|
||||
<Box component="section" sx={{ mb: 5 }}>
|
||||
<Typography variant="h5" gutterBottom>Mercado Agroganadero de Cañuelas</Typography>
|
||||
<MercadoAgroWidget />
|
||||
</Box>
|
||||
</Box>*/}
|
||||
|
||||
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
|
||||
<Box component="section" sx={{ mb: 5 }}>
|
||||
@@ -50,11 +50,11 @@ function App() {
|
||||
<GranosWidget />
|
||||
</Box>
|
||||
|
||||
{/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */}
|
||||
{/* --- 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>
|
||||
</Box>*/}
|
||||
|
||||
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
|
||||
<Box component="section" sx={{ mb: 5 }}>
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,67 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
// Importamos TODOS los widgets que queremos que estén disponibles
|
||||
import { MercadoAgroWidget } from './components/MercadoAgroWidget';
|
||||
import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
|
||||
import { GranosWidget } from './components/GranosWidget';
|
||||
import { GranosCardWidget } from './components/GranosCardWidget';
|
||||
import { BolsaUsaWidget } from './components/BolsaUsaWidget';
|
||||
import { UsaIndexHeroCard } from './components/UsaIndexHeroCard';
|
||||
import { BolsaLocalWidget } from './components/BolsaLocalWidget';
|
||||
import { MervalHeroCard } from './components/MervalHeroCard';
|
||||
import { RawDataView } from './pages/RawDataView';
|
||||
|
||||
// El registro de todos nuestros widgets disponibles
|
||||
const widgetRegistry = {
|
||||
// Widgets del Mercado Local
|
||||
'merval-heroe': MervalHeroCard,
|
||||
'bolsa-local-tabla': BolsaLocalWidget,
|
||||
|
||||
// Widgets del Mercado de EEUU
|
||||
'sp500-heroe': UsaIndexHeroCard,
|
||||
'bolsa-eeuu-tabla': BolsaUsaWidget,
|
||||
|
||||
// Widgets de Granos
|
||||
'granos-tarjetas': GranosCardWidget,
|
||||
'granos-tabla': GranosWidget,
|
||||
|
||||
// Widgets del Mercado Agroganadero
|
||||
'mercado-agro-tarjetas': MercadoAgroCardWidget,
|
||||
'mercado-agro-tabla': MercadoAgroWidget,
|
||||
|
||||
// Página completa como un widget
|
||||
'pagina-datos-crudos': RawDataView,
|
||||
};
|
||||
|
||||
// Tema base de Material-UI
|
||||
const theme = createTheme();
|
||||
|
||||
// Exponemos la función de renderizado en el objeto window
|
||||
// @ts-ignore
|
||||
window.MercadosWidgets = {
|
||||
render: () => {
|
||||
const widgetContainers = document.querySelectorAll<HTMLElement>('[data-mercado-widget]');
|
||||
widgetContainers.forEach(container => {
|
||||
const widgetName = container.dataset.mercadoWidget as keyof typeof widgetRegistry;
|
||||
if (widgetName && widgetRegistry[widgetName]) {
|
||||
const WidgetComponent = widgetRegistry[widgetName];
|
||||
ReactDOM.createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<WidgetComponent />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Si estamos en modo de desarrollo, llamamos a render automáticamente
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.MercadosWidgets.render();
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface CotizacionGrano {
|
||||
export interface CotizacionBolsa {
|
||||
id: number;
|
||||
ticker: string;
|
||||
nombreEmpresa?: string;
|
||||
mercado: string;
|
||||
precioActual: number;
|
||||
apertura: number;
|
||||
|
||||
22
frontend/src/pages/RawDataPage.tsx
Normal file
22
frontend/src/pages/RawDataPage.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { AppBar, Toolbar, Typography, Button } from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { RawDataView } from './RawDataView'; // <-- Importamos la vista
|
||||
|
||||
// Este componente es una "Página" completa, con su propio layout.
|
||||
// Solo debe ser usado dentro de un Router.
|
||||
export const RawDataPage = () => {
|
||||
return (
|
||||
<>
|
||||
<AppBar position="static" sx={{ backgroundColor: '#333' }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Datos para Redacción
|
||||
</Typography>
|
||||
<Button color="inherit" component={Link} to="/">Volver a la vista principal</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
{/* Reutilizamos el contenido */}
|
||||
<RawDataView />
|
||||
</>
|
||||
);
|
||||
};
|
||||
22
frontend/src/pages/RawDataView.tsx
Normal file
22
frontend/src/pages/RawDataView.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Container, Typography } from '@mui/material';
|
||||
import { RawAgroTable } from '../components/raw-data/RawAgroTable';
|
||||
import { RawGranosTable } from '../components/raw-data/RawGranosTable';
|
||||
import { RawBolsaUsaTable } from '../components/raw-data/RawBolsaUsaTable';
|
||||
import { RawBolsaLocalTable } from '../components/raw-data/RawBolsaLocalTable';
|
||||
|
||||
// Este componente solo muestra el contenido, sin la barra de navegación.
|
||||
// Es seguro para ser embebido en cualquier lugar.
|
||||
export const RawDataView = () => {
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>Mercado Agroganadero</Typography>
|
||||
<RawAgroTable />
|
||||
<Typography variant="h4" gutterBottom sx={{ mt: 4 }}>Granos</Typography>
|
||||
<RawGranosTable />
|
||||
<Typography variant="h4" gutterBottom sx={{ mt: 4 }}>Bolsa de Valores EEUU</Typography>
|
||||
<RawBolsaUsaTable />
|
||||
<Typography variant="h4" gutterBottom sx={{ mt: 4 }}>Bolsa de Valores Argentina</Typography>
|
||||
<RawBolsaLocalTable />
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
34
frontend/src/utils/clipboardUtils.ts
Normal file
34
frontend/src/utils/clipboardUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const copyToClipboard = (text: string): Promise<void> => {
|
||||
// Intenta usar la API moderna (navigator.clipboard)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback para navegadores antiguos o contextos no seguros (HTTP, IPs)
|
||||
return new Promise((resolve, reject) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
|
||||
// Haz el textarea invisible
|
||||
textArea.style.position = 'absolute';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '0';
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('No se pudo copiar el texto.'));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,7 @@ export const formatCurrency = (num: number, currency = 'ARS') => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: style,
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'; // Importa el módulo 'path' de Node
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true, // o "0.0.0.0"
|
||||
port: 5173 // el puerto que uses, opcional
|
||||
// --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V ---
|
||||
build: {
|
||||
lib: {
|
||||
// La entrada a nuestra librería. Apunta a nuestro main.tsx
|
||||
entry: path.resolve(__dirname, 'src/main.tsx'),
|
||||
// El nombre de la variable global que se expondrá
|
||||
name: 'MercadosWidgets',
|
||||
// El nombre del archivo de salida
|
||||
fileName: (format) => `mercados-widgets.${format}.js`,
|
||||
},
|
||||
// No necesitamos minificar el CSS si es simple, pero es buena práctica
|
||||
cssCodeSplit: true,
|
||||
// Generar un manifest para saber qué archivos se crearon
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
// Asegúrate de no externalizar React, para que se incluya en el bundle
|
||||
external: [],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user