feat: adaptación de los proyectos para utilizar .env y comienzo de preparación para despliegue en docker

This commit is contained in:
2025-07-03 11:44:10 -03:00
parent ab9e77fa81
commit 93b2887bd5
49 changed files with 1610 additions and 356 deletions

7
.env Normal file
View File

@@ -0,0 +1,7 @@
# --- Conexión a la Base de Datos ---
DB_CONNECTION_STRING="Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;"
# --- Claves de APIs Externas ---
FINNHUB_API_KEY="cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30"
BCR_API_KEY="D1782A51-A5FD-EF11-9445-00155D09E201"
BCR_API_SECRET="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"

View File

@@ -1,13 +1,63 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/eldia.svg" /> <link rel="icon" type="image/svg+xml" href="/eldia.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> </head>
<body> <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> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -17,10 +17,12 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.6.3",
"recharts": "^3.0.2" "recharts": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
@@ -1901,6 +1903,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -2522,6 +2534,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cosmiconfig": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@@ -4090,6 +4111,44 @@
"node": ">=0.10.0" "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -4274,6 +4333,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4487,6 +4552,13 @@
"typescript": ">=4.8.4 <5.9.0" "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": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",

View File

@@ -19,10 +19,12 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.6.3",
"recharts": "^3.0.2" "recharts": "^3.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",

View File

@@ -24,11 +24,11 @@ function App() {
Datos del Mercado Datos del Mercado
</Typography> </Typography>
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */} {/* --- Sección 1: Mercado Agroganadero de Cañuelas ---
<Box component="section" sx={{ mb: 5 }}> <Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Mercado Agroganadero de Cañuelas</Typography> <Typography variant="h5" gutterBottom>Mercado Agroganadero de Cañuelas</Typography>
<MercadoAgroWidget /> <MercadoAgroWidget />
</Box> </Box>*/}
{/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */} {/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */}
<Box component="section" sx={{ mb: 5 }}> <Box component="section" sx={{ mb: 5 }}>
@@ -50,11 +50,11 @@ function App() {
<GranosWidget /> <GranosWidget />
</Box> </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 }}> <Box component="section" sx={{ mb: 5 }}>
<Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography> <Typography variant="h5" gutterBottom>Granos - Bolsa de Comercio de Rosario</Typography>
<GranosWidget /> <GranosWidget />
</Box> </Box>*/}
{/* --- Sección 3: Mercado de Valores de Estados Unidos --- */} {/* --- Sección 3: Mercado de Valores de Estados Unidos --- */}
<Box component="section" sx={{ mb: 5 }}> <Box component="section" sx={{ mb: 5 }}>

View 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>
);
};

View File

@@ -8,12 +8,11 @@ import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove'; 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 type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData'; import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget'; import { HistoricalChartWidget } from './HistoricalChartWidget';
import { PiChartLineUpBold } from 'react-icons/pi';
const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
const Variacion = ({ value }: { value: number }) => { const Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
@@ -21,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => {
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <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> </Box>
); );
}; };
@@ -30,13 +29,10 @@ export const BolsaLocalWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
const [selectedTicker, setSelectedTicker] = useState<string | null>(null); const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
const handleRowClick = (ticker: string) => { const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
setSelectedTicker(ticker); const handleCloseDialog = () => setSelectedTicker(null);
};
const handleCloseDialog = () => { const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || [];
setSelectedTicker(null);
};
if (loading) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -47,54 +43,75 @@ export const BolsaLocalWidget = () => {
} }
if (!data || data.length === 0) { 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 ( 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> {panelPrincipal.length > 0 && (
<DialogTitle sx={{ m: 0, p: 2 }}> <TableContainer component={Paper}>
Historial de 30 días para: {selectedTicker} <Box sx={{ p: 1, m: 0 }}>
<IconButton <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
aria-label="close" Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)}
onClick={handleCloseDialog} </Typography>
sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }} </Box>
> <Table size="small" aria-label="panel principal merval">
<CloseIcon /> <TableHead>
</IconButton> <TableRow>
</DialogTitle> <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> <DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" />} {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>

View File

@@ -8,14 +8,11 @@ import CloseIcon from '@mui/icons-material/Close';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove'; 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 type { CotizacionBolsa } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData'; import { useApiData } from '../hooks/useApiData';
import { HistoricalChartWidget } from './HistoricalChartWidget'; import { HistoricalChartWidget } from './HistoricalChartWidget';
import { PiChartLineUpBold } from 'react-icons/pi';
// 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 Variacion = ({ value }: { value: number }) => {
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
@@ -23,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => {
return ( return (
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> <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> </Box>
); );
}; };
@@ -32,13 +29,10 @@ export const BolsaUsaWidget = () => {
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
const [selectedTicker, setSelectedTicker] = useState<string | null>(null); const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
const handleRowClick = (ticker: string) => { const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
setSelectedTicker(ticker); const handleCloseDialog = () => setSelectedTicker(null);
};
const handleCloseDialog = () => { const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || [];
setSelectedTicker(null);
};
if (loading) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -48,56 +42,64 @@ export const BolsaUsaWidget = () => {
return <Alert severity="error">{error}</Alert>; return <Alert severity="error">{error}</Alert>;
} }
// Recordatorio de que el fetcher puede estar desactivado
if (!data || data.length === 0) { 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 ( return (
<> <>
<TableContainer component={Paper}> {/* Renderizamos la tabla solo si hay otras acciones */}
<Box sx={{ p: 1, pb: 0 }}> {otherStocks.length > 0 && (
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> <TableContainer component={Paper}>
Última actualización: {formatFullDateTime(data[0].fechaRegistro)} <Box sx={{ p: 1, m: 0 }}>
</Typography> <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
</Box> Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)}
<Table size="small" aria-label="tabla bolsa eeuu"> </Typography>
<TableHead> </Box>
<TableRow> <Table size="small" aria-label="panel principal eeuu">
<TableCell>Símbolo</TableCell> <TableHead>
<TableCell align="right">Precio Actual</TableCell> <TableRow>
<TableCell align="right">Apertura</TableCell> <TableCell>Símbolo</TableCell>
<TableCell align="right">Cierre Anterior</TableCell> <TableCell align="right">Precio Actual</TableCell>
<TableCell align="center">% Cambio</TableCell>
</TableRow> <TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
</TableHead> <TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
<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> <TableCell align="center">% Cambio</TableCell>
<DialogTitle sx={{ m: 0, p: 2 }}> <TableCell align="center">Historial</TableCell>
Historial de 30 días para: {selectedTicker} </TableRow>
<IconButton </TableHead>
aria-label="close" <TableBody>
onClick={handleCloseDialog} {otherStocks.map((row) => (
sx={{ position: 'absolute', right: 8, top: 8, color: (theme) => theme.palette.grey[500] }} <TableRow key={row.ticker} hover>
> <TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
<CloseIcon /> <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
</IconButton>
</DialogTitle> <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> <DialogContent dividers>
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" />} {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>

View 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>
);
};

View File

@@ -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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import RemoveIcon from '@mui/icons-material/Remove'; import RemoveIcon from '@mui/icons-material/Remove';
// Iconos de react-icons para cada grano
import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi"; import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi";
import { TbGrain } from "react-icons/tb"; import { TbGrain } from "react-icons/tb";
import type { CotizacionGrano } from '../models/mercadoModels'; import type { CotizacionGrano } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData'; 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) => { const getGrainIcon = (nombre: string) => {
switch (nombre.toLowerCase()) { switch (nombre.toLowerCase()) {
case 'girasol': case 'girasol': return <GiSunflower size={28} color="#fbc02d" />;
return <GiSunflower size={28} color="#fbc02d" />; case 'trigo': return <GiWheat size={28} color="#fbc02d" />;
case 'trigo': case 'sorgo': return <TbGrain size={28} color="#fbc02d" />;
return <GiWheat size={28} color="#fbc02d" />; case 'maiz': return <GiCorn size={28} color="#fbc02d" />;
case 'sorgo': default: return <GiGrain size={28} color="#fbc02d" />;
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 // Subcomponente para una única tarjeta de grano
const GranoCard = ({ grano }: { grano: CotizacionGrano }) => { const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => {
const isPositive = grano.variacionPrecio > 0; const isPositive = grano.variacionPrecio > 0;
const isNegative = grano.variacionPrecio < 0; const isNegative = grano.variacionPrecio < 0;
const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary';
const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; 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 ( <Box sx={{ textAlign: 'center', my: 1 }}>
<Paper <Typography variant="h5" component="p" sx={{ fontWeight: 'bold' }}>
elevation={2} ${formatInteger(grano.precio)}
sx={{ </Typography>
p: 2, <Typography variant="caption" color="text.secondary">
display: 'flex', por Tonelada
flexDirection: 'column', </Typography>
justifyContent: 'space-between', </Box>
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 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
<Typography variant="h5" component="p" sx={{ fontWeight: 'bold' }}> <Icon sx={{ fontSize: '1.1rem', mr: 0.5 }} />
${formatCurrency(grano.precio)} <Typography variant="body2" sx={{ fontWeight: 'bold' }}>
</Typography> {formatInteger(grano.variacionPrecio)}
<Typography variant="caption" color="text.secondary"> </Typography>
por Tonelada </Box>
</Typography> <Typography variant="caption" align="center" sx={{ mt: 1, color: 'text.secondary' }}>
</Box> Operación: {formatDateOnly(grano.fechaOperacion)}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> </Paper>
<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 = () => { export const GranosCardWidget = () => {
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); 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) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
@@ -93,10 +122,47 @@ export const GranosCardWidget = () => {
} }
return ( 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) => ( {data.map((grano) => (
<GranoCard key={grano.nombre} grano={grano} /> <GranoCard key={grano.nombre} grano={grano} onChartClick={(event) => handleChartClick(grano.nombre, event)} />
))} ))}
</Box> </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>
</>
); );
}; };

View File

@@ -10,7 +10,7 @@ import RemoveIcon from '@mui/icons-material/Remove';
const formatNumber = (num: number) => { const formatNumber = (num: number) => {
return new Intl.NumberFormat('es-AR', { return new Intl.NumberFormat('es-AR', {
minimumFractionDigits: 2, minimumFractionDigits: 0,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(num); }).format(num);
}; };

View File

@@ -6,6 +6,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
interface HistoricalChartWidgetProps { interface HistoricalChartWidgetProps {
ticker: string; ticker: string;
mercado: 'Local' | 'EEUU'; mercado: 'Local' | 'EEUU';
dias: number;
} }
// Formateador para el eje X (muestra DD/MM) // 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' }); return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
}; };
export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidgetProps) => { export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => {
// Usamos el hook para obtener los datos del historial de los últimos 30 días const apiUrl = `/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=${dias}`;
const { data, loading, error } = useApiData<CotizacionBolsa[]>(`/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=30`); const { data, loading, error } = useApiData<CotizacionBolsa[]>(apiUrl);
if (loading) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: 300 }}><CircularProgress /></Box>; 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')}`} /> <YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} /> <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} />
<Legend /> <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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
); );

View File

@@ -1,39 +1,75 @@
import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material'; import { useState } from 'react';
import { PiCow } from "react-icons/pi"; // Un icono divertido para "cabezas" import {
import ScaleIcon from '@mui/icons-material/Scale'; // Para kilos 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 type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData'; import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatInteger } from '../utils/formatters'; 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 ( return (
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px' }}> // Añadimos posición relativa para poder posicionar el botón del gráfico.
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2 }}> <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}>
{categoria.categoria} <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> </Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> <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>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> <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>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography> <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>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<PiCow size={28}/> <PiCow size="28" />
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(categoria.cabezas)}</Typography> <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography>
<Typography variant="caption" color="text.secondary">Cabezas</Typography> <Typography variant="caption" color="text.secondary">Cabezas</Typography>
</Box> </Box>
<Box sx={{ textAlign: 'center' }}> <Box sx={{ textAlign: 'center' }}>
<ScaleIcon color="action" /> <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> <Typography variant="caption" color="text.secondary">Kilos</Typography>
</Box> </Box>
</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 = () => { export const MercadoAgroCardWidget = () => {
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); 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) { if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; 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>; 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 ( return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}> <>
{Object.values(resumenPorCategoria).map(categoria => ( <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
<AgroCard key={categoria.categoria} categoria={categoria} /> {data.map(registro => (
))} <AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} />
</Box> ))}
</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>
</>
); );
}; };

View File

@@ -4,66 +4,114 @@ import {
} from '@mui/material'; } from '@mui/material';
import type { CotizacionGanado } from '../models/mercadoModels'; import type { CotizacionGanado } from '../models/mercadoModels';
import { useApiData } from '../hooks/useApiData'; import { useApiData } from '../hooks/useApiData';
import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters';
const formatNumber = (num: number, fractionDigits = 2) => { // --- V INICIO DE LA MODIFICACIÓN V ---
return new Intl.NumberFormat('es-AR', { // El sub-componente ahora solo necesita renderizar la tarjeta de móvil.
minimumFractionDigits: fractionDigits, // La fila de la tabla la haremos directamente en el componente principal.
maximumFractionDigits: fractionDigits, const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
}).format(num); 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 = () => { export const MercadoAgroWidget = () => {
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
if (loading) { if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; }
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 (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 ( return (
<TableContainer component={Paper}> <Box>
<Table size="small" aria-label="tabla mercado agroganadero"> {/* VISTA DE ESCRITORIO (se oculta en móvil) */}
<TableHead> <TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}>
<TableRow> <Table size="small" aria-label="tabla mercado agroganadero">
<TableCell>Categoría</TableCell> <TableHead>
<TableCell>Especificaciones</TableCell> <TableRow>
<TableCell align="right">Máximo</TableCell> <TableCell>Categoría</TableCell>
<TableCell align="right">Mínimo</TableCell> <TableCell>Especificaciones</TableCell>
<TableCell align="right">Mediano</TableCell> <TableCell align="right">Máximo</TableCell>
<TableCell align="right">Cabezas</TableCell> <TableCell align="right">Mínimo</TableCell>
<TableCell align="right">Kilos Totales</TableCell> <TableCell align="right">Mediano</TableCell>
<TableCell align="right">Importe Total</TableCell> <TableCell align="right">Cabezas</TableCell>
</TableRow> <TableCell align="right">Kg Total</TableCell>
</TableHead> <TableCell align="right">Importe Total</TableCell>
<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>
</TableRow> </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> </Box>
</Table>
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> <Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}>
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
Fuente: Mercado Agroganadero S.A. Fuente: Mercado Agroganadero S.A.
</Typography> </Typography>
</Tooltip> </Tooltip>
</TableContainer> </Box>
); );
}; };

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -1,9 +1,67 @@
import { StrictMode } from 'react' import React from 'react';
import { createRoot } from 'react-dom/client' import ReactDOM from 'react-dom/client';
import App from './App.tsx' import { CssBaseline, ThemeProvider, createTheme } from '@mui/material';
createRoot(document.getElementById('root')!).render( // Importamos TODOS los widgets que queremos que estén disponibles
<StrictMode> import { MercadoAgroWidget } from './components/MercadoAgroWidget';
<App /> import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget';
</StrictMode>, 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();
}

View File

@@ -28,6 +28,7 @@ export interface CotizacionGrano {
export interface CotizacionBolsa { export interface CotizacionBolsa {
id: number; id: number;
ticker: string; ticker: string;
nombreEmpresa?: string;
mercado: string; mercado: string;
precioActual: number; precioActual: number;
apertura: number; apertura: number;

View 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 />
</>
);
};

View 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>
);
};

View 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);
}
});
}
};

View File

@@ -6,7 +6,7 @@ export const formatCurrency = (num: number, currency = 'ARS') => {
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
style: style, style: style,
currency: currency, currency: currency,
minimumFractionDigits: 2, minimumFractionDigits: 0,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(num); }).format(num);
}; };

View File

@@ -1,11 +1,33 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'; // Importa el módulo 'path' de Node
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { // --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V ---
host: true, // o "0.0.0.0" build: {
port: 5173 // el puerto que uses, opcional 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'
}
}
}
} }
}) })

View File

@@ -113,5 +113,39 @@ namespace Mercados.Api.Controllers
return StatusCode(500, "Ocurrió un error interno en el servidor."); return StatusCode(500, "Ocurrió un error interno en el servidor.");
} }
} }
[HttpGet("agroganadero/history")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetAgroganaderoHistory([FromQuery] string categoria, [FromQuery] string especificaciones, [FromQuery] int dias = 30)
{
try
{
var data = await _ganadoRepo.ObtenerHistorialAsync(categoria, especificaciones, dias);
return Ok(data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener historial para la categoría {Categoria}.", categoria);
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
[HttpGet("granos/history/{nombre}")]
[ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetGranoHistory(string nombre, [FromQuery] int dias = 30)
{
try
{
var data = await _granoRepo.ObtenerHistorialAsync(nombre, dias);
return Ok(data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error al obtener historial para el grano {Grano}.", nombre);
return StatusCode(500, "Ocurrió un error interno en el servidor.");
}
}
} }
} }

View File

@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="FluentMigrator.Runner" Version="7.1.0" /> <PackageReference Include="FluentMigrator.Runner" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />

View File

@@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence;
using Mercados.Infrastructure.Persistence.Repositories; using Mercados.Infrastructure.Persistence.Repositories;
using System.Reflection; using System.Reflection;
// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
DotNetEnv.Env.Load();
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Nombre para política de CORS // Nombre para política de CORS
@@ -16,7 +19,10 @@ builder.Services.AddCors(options =>
options.AddPolicy(name: MyAllowSpecificOrigins, options.AddPolicy(name: MyAllowSpecificOrigins,
policy => policy =>
{ {
policy.WithOrigins("http://localhost:5173", "http://192.168.10.78:5173") policy.WithOrigins("http://localhost:5173", // Desarrollo Frontend
"http://192.168.10.78:5173", // Desarrollo en Red Local
"https://www.eldia.com" // <--- DOMINIO DE PRODUCCIÓN
)
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod(); .AllowAnyMethod();
}); });

View File

@@ -7,13 +7,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" "DefaultConnection": ""
}, },
"ApiKeys": { "ApiKeys": {
"Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", "Finnhub": "",
"Bcr": { "Bcr": {
"Key": "D1782A51-A5FD-EF11-9445-00155D09E201", "Key": "",
"Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" "Secret": ""
} }
} }
} }

View File

@@ -4,6 +4,7 @@ namespace Mercados.Core.Entities
{ {
public long Id { get; set; } public long Id { get; set; }
public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc. public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc.
public string? NombreEmpresa { get; set; }
public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local" public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local"
public decimal PrecioActual { get; set; } public decimal PrecioActual { get; set; }
public decimal Apertura { get; set; } public decimal Apertura { get; set; }

View File

@@ -1,6 +0,0 @@
namespace Mercados.Database;
public class Class1
{
}

View File

@@ -0,0 +1,19 @@
using FluentMigrator;
namespace Mercados.Database.Migrations
{
[Migration(20240702133000)]
public class AddNameToStocks : Migration
{
public override void Up()
{
Alter.Table("CotizacionesBolsa")
.AddColumn("NombreEmpresa").AsString(255).Nullable();
}
public override void Down()
{
Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa");
}
}
}

View File

@@ -1,6 +0,0 @@
namespace Mercados.Infrastructure;
public class Class1
{
}

View File

@@ -122,8 +122,8 @@ namespace Mercados.Infrastructure.DataFetchers
private async Task<string?> GetAuthTokenAsync(HttpClient client) private async Task<string?> GetAuthTokenAsync(HttpClient client)
{ {
var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login");
request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY"));
request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET"));
var response = await client.SendAsync(request); var response = await client.SendAsync(request);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();

View File

@@ -11,9 +11,12 @@ namespace Mercados.Infrastructure.DataFetchers
{ {
public string SourceName => "Finnhub"; public string SourceName => "Finnhub";
private readonly List<string> _tickers = new() { private readonly List<string> _tickers = new() {
"AAPL", "AMD", "AMZN", "BRK-B", "KO", "MSFT", "NVDA", "GLD", // Tecnológicas y ETFs
"XLF", "XLI", "XLE", "XLK", "YPF", "GGAL", "BMA", "TEO", "AAPL", "MSFT", "AMZN", "NVDA", "AMD", "KO", "BRK-B", "GLD", "XLF", "XLI", "XLE", "XLK",
"PAM", "CEPU", "LOMA", "CRESY", "BBAR", "TGS", "EDN", "MELI", "GLOB" // Empresas 'Latinas' en Wall Street
"MELI", "GLOB",
// ADRs Argentinos
"YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR"
}; };
private readonly FinnhubClient _client; private readonly FinnhubClient _client;
@@ -28,7 +31,7 @@ namespace Mercados.Infrastructure.DataFetchers
IFuenteDatoRepository fuenteDatoRepository, IFuenteDatoRepository fuenteDatoRepository,
ILogger<FinnhubDataFetcher> logger) ILogger<FinnhubDataFetcher> logger)
{ {
var apiKey = configuration["ApiKeys:Finnhub"]; var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY");
if (string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(apiKey))
{ {
throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)"); throw new InvalidOperationException("La clave de API de Finnhub no está configurada en appsettings.json (ApiKeys:Finnhub)");
@@ -53,9 +56,11 @@ namespace Mercados.Infrastructure.DataFetchers
if (quote.Current == 0 || quote.PreviousClose == 0) continue; if (quote.Current == 0 || quote.PreviousClose == 0) continue;
var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100; var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100;
cotizaciones.Add(new CotizacionBolsa cotizaciones.Add(new CotizacionBolsa
{ {
Ticker = ticker, Ticker = ticker,
NombreEmpresa = TickerNameMapping.GetName(ticker),
Mercado = "EEUU", Mercado = "EEUU",
PrecioActual = (decimal)quote.Current, PrecioActual = (decimal)quote.Current,
Apertura = (decimal)quote.Open, Apertura = (decimal)quote.Open,

View File

@@ -0,0 +1,61 @@
namespace Mercados.Infrastructure.DataFetchers
{
public static class TickerNameMapping
{
private static readonly Dictionary<string, string> Names = new(StringComparer.OrdinalIgnoreCase)
{
// USA
{ "SPY", "S&P 500 ETF" }, // Cambiado de GSPC a SPY para Finnhub
{ "AAPL", "Apple Inc." },
{ "MSFT", "Microsoft Corp." },
{ "AMZN", "Amazon.com, Inc." },
{ "NVDA", "NVIDIA Corp." },
{ "AMD", "Advanced Micro Devices" },
{ "KO", "The Coca-Cola Company" },
{ "BRK-B", "Berkshire Hathaway Inc." },
{ "GLD", "SPDR Gold Shares" },
{ "XLF", "Financial Select Sector SPDR" },
{ "XLI", "Industrial Select Sector SPDR" },
{ "XLE", "Energy Select Sector SPDR" },
{ "XLK", "Technology Select Sector SPDR" },
{ "MELI", "MercadoLibre, Inc." },
{ "GLOB", "Globant" },
// ADRs Argentinos que cotizan en EEUU
{ "YPF", "YPF S.A. (ADR)" },
{ "GGAL", "Grupo Financiero Galicia (ADR)" },
{ "BMA", "Banco Macro (ADR)" },
{ "LOMA", "Loma Negra (ADR)" },
{ "PAM", "Pampa Energía (ADR)" },
{ "TEO", "Telecom Argentina (ADR)" },
{ "TGS", "Transportadora de Gas del Sur (ADR)" },
{ "EDN", "Edenor (ADR)" },
{ "CRESY", "Cresud (ADR)" },
{ "CEPU", "Central Puerto (ADR)" },
{ "BBAR", "BBVA Argentina (ADR)" },
// Argentina Local
{ "^GSPC", "S&P 500 Index" }, // Lo dejamos por si Yahoo lo devuelve
{ "^MERV", "S&P Merval" },
{ "GGAL.BA", "Grupo Financiero Galicia" },
{ "YPFD.BA", "YPF S.A." },
{ "PAMP.BA", "Pampa Energía" },
{ "BMA.BA", "Banco Macro" },
{ "COME.BA", "Sociedad Comercial del Plata" },
{ "TECO2.BA", "Telecom Argentina" },
{ "EDN.BA", "Edenor" },
{ "CRES.BA", "Cresud" },
{ "TXAR.BA", "Ternium Argentina" },
{ "MIRG.BA", "Mirgor" },
{ "CEPU.BA", "Central Puerto" },
{ "LOMA.BA", "Loma Negra" },
{ "VALO.BA", "Banco de Valores" },
{ "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR
};
public static string? GetName(string ticker)
{
return Names.GetValueOrDefault(ticker);
}
}
}

View File

@@ -9,9 +9,10 @@ namespace Mercados.Infrastructure.DataFetchers
{ {
public string SourceName => "YahooFinance"; public string SourceName => "YahooFinance";
private readonly List<string> _tickers = new() { private readonly List<string> _tickers = new() {
"^GSPC", // Índice S&P 500
"^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA",
"TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA", "TECO2.BA", "EDN.BA", "CRES.BA", "TXAR.BA", "MIRG.BA",
"CEPU.BA", "LOMA.BA", "VALO.BA" "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA"
}; };
private readonly ICotizacionBolsaRepository _cotizacionRepository; private readonly ICotizacionBolsaRepository _cotizacionRepository;
@@ -33,7 +34,6 @@ namespace Mercados.Infrastructure.DataFetchers
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
try try
{ {
// La librería puede obtener múltiples tickers en una sola llamada.
var securities = await Yahoo.Symbols(_tickers.ToArray()).Fields(Field.RegularMarketPrice, Field.RegularMarketOpen, Field.RegularMarketPreviousClose, Field.RegularMarketChangePercent).QueryAsync(); var securities = await Yahoo.Symbols(_tickers.ToArray()).Fields(Field.RegularMarketPrice, Field.RegularMarketOpen, Field.RegularMarketPreviousClose, Field.RegularMarketChangePercent).QueryAsync();
var cotizaciones = new List<CotizacionBolsa>(); var cotizaciones = new List<CotizacionBolsa>();
@@ -41,10 +41,13 @@ namespace Mercados.Infrastructure.DataFetchers
{ {
if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue;
string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU";
cotizaciones.Add(new CotizacionBolsa cotizaciones.Add(new CotizacionBolsa
{ {
Ticker = sec.Symbol, Ticker = sec.Symbol,
Mercado = "Local", NombreEmpresa = TickerNameMapping.GetName(sec.Symbol),
Mercado = mercado,
PrecioActual = (decimal)sec.RegularMarketPrice, PrecioActual = (decimal)sec.RegularMarketPrice,
Apertura = (decimal)sec.RegularMarketOpen, Apertura = (decimal)sec.RegularMarketOpen,
CierreAnterior = (decimal)sec.RegularMarketPreviousClose, CierreAnterior = (decimal)sec.RegularMarketPreviousClose,

View File

@@ -18,8 +18,8 @@ namespace Mercados.Infrastructure.Persistence.Repositories
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @" const string sql = @"
INSERT INTO CotizacionesBolsa (Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro) INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)
VALUES (@Ticker, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);";
await connection.ExecuteAsync(sql, cotizaciones); await connection.ExecuteAsync(sql, cotizaciones);
} }
@@ -27,10 +27,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado)
{ {
using IDbConnection connection = _connectionFactory.CreateConnection(); using IDbConnection connection = _connectionFactory.CreateConnection();
// Esta consulta usa una "Common Table Expression" (CTE)
// y la función ROW_NUMBER() para obtener el registro más reciente para cada Ticker
// dentro del mercado especificado. Es extremadamente eficiente.
const string sql = @" const string sql = @"
WITH RankedCotizaciones AS ( WITH RankedCotizaciones AS (
SELECT SELECT
@@ -42,7 +39,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
Mercado = @Mercado Mercado = @Mercado
) )
SELECT SELECT
Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
FROM FROM
RankedCotizaciones RankedCotizaciones
WHERE WHERE
@@ -57,7 +54,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
const string sql = @" const string sql = @"
SELECT SELECT
Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro
FROM FROM
CotizacionesBolsa CotizacionesBolsa
WHERE WHERE
@@ -65,7 +62,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories
AND Mercado = @Mercado AND Mercado = @Mercado
AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE()) AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
ORDER BY ORDER BY
FechaRegistro ASC;"; // ASC es importante para dibujar la línea del gráfico FechaRegistro ASC;";
return await connection.QueryAsync<CotizacionBolsa>(sql, new { Ticker = ticker, Mercado = mercado, Dias = dias }); return await connection.QueryAsync<CotizacionBolsa>(sql, new { Ticker = ticker, Mercado = mercado, Dias = dias });
} }

View File

@@ -43,5 +43,28 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionGanado>(sql); return await connection.QueryAsync<CotizacionGanado>(sql);
} }
public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"
SELECT
*
FROM
CotizacionesGanado
WHERE
Categoria = @Categoria
AND Especificaciones = @Especificaciones
AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
ORDER BY
FechaRegistro ASC;";
return await connection.QueryAsync<CotizacionGanado>(sql, new {
Categoria = categoria,
Especificaciones = especificaciones,
Dias = dias
});
}
} }
} }

View File

@@ -44,5 +44,23 @@ namespace Mercados.Infrastructure.Persistence.Repositories
return await connection.QueryAsync<CotizacionGrano>(sql); return await connection.QueryAsync<CotizacionGrano>(sql);
} }
public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias)
{
using IDbConnection connection = _connectionFactory.CreateConnection();
const string sql = @"
SELECT
*
FROM
CotizacionesGranos
WHERE
Nombre = @Nombre
AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE())
ORDER BY
FechaRegistro ASC;";
return await connection.QueryAsync<CotizacionGrano>(sql, new { Nombre = nombre, Dias = dias });
}
} }
} }

View File

@@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{ {
Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones); Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones);
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync(); Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync();
Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
} }
} }

View File

@@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
{ {
Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones);
Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync(); Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync();
Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias);
} }
} }

View File

@@ -11,8 +11,9 @@ namespace Mercados.Infrastructure
public SqlConnectionFactory(IConfiguration configuration) public SqlConnectionFactory(IConfiguration configuration)
{ {
_connectionString = configuration.GetConnectionString("DefaultConnection") // Leemos directamente de la variable de entorno
?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada.");
} }
public IDbConnection CreateConnection() public IDbConnection CreateConnection()

View File

@@ -2,70 +2,109 @@ using Mercados.Infrastructure.DataFetchers;
namespace Mercados.Worker namespace Mercados.Worker
{ {
/// <summary>
/// Servicio de fondo que orquesta la obtención de datos de diversas fuentes
/// de forma programada y periódica.
/// </summary>
public class DataFetchingService : BackgroundService public class DataFetchingService : BackgroundService
{ {
private readonly ILogger<DataFetchingService> _logger; private readonly ILogger<DataFetchingService> _logger;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly TimeZoneInfo _argentinaTimeZone;
// Diccionario para rastrear la última vez que se ejecutó una tarea diaria. // Diccionario para rastrear la última vez que se ejecutó una tarea diaria
// y evitar que se ejecute múltiples veces si el servicio se reinicia.
private readonly Dictionary<string, DateTime> _lastDailyRun = new(); private readonly Dictionary<string, DateTime> _lastDailyRun = new();
public DataFetchingService(ILogger<DataFetchingService> logger, IServiceProvider serviceProvider) public DataFetchingService(ILogger<DataFetchingService> logger, IServiceProvider serviceProvider)
{ {
_logger = logger; _logger = logger;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
// Se define explícitamente la zona horaria de Argentina.
// Esto asegura que los cálculos de tiempo sean correctos, sin importar
// la configuración de zona horaria del servidor donde se ejecute el worker.
try
{
// El ID estándar para Linux y macOS
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
}
catch (TimeZoneNotFoundException)
{
// El ID equivalente para Windows
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
}
} }
/// <summary>
/// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now);
// Ejecutamos una vez al inicio para tener datos frescos inmediatamente. // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente
await RunAllFetchersAsync(); // al iniciar el servicio, en lugar de esperar al primer horario programado.
//await RunAllFetchersAsync(stoppingToken);
// Usamos un PeriodicTimer que "despierta" cada minuto para revisar si hay tareas pendientes. // PeriodicTimer es una forma moderna y eficiente de crear un bucle de "tic-tac"
// sin bloquear un hilo con Task.Delay.
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1)); using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(stoppingToken)) // El bucle se ejecuta cada minuto mientras el servicio no reciba una señal de detención.
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{ {
await RunScheduledTasksAsync(); await RunScheduledTasksAsync(stoppingToken);
} }
} }
private async Task RunScheduledTasksAsync() /// <summary>
/// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado.
/// </summary>
private async Task RunScheduledTasksAsync(CancellationToken stoppingToken)
{ {
// --- Lógica de Planificación --- // Se obtiene la hora actual convertida a la zona horaria de Argentina.
var now = DateTime.Now; var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone);
// Tarea 1: Mercado Agroganadero (todos los días a las 11:00) // --- Tarea 1: Mercado Agroganadero (L-V a las 11:00 AM) ---
if (now.Hour == 11 && now.Minute == 0 && HasNotRunToday("MercadoAgroganadero")) if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 0 && HasNotRunToday("MercadoAgroganadero"))
{ {
await RunFetcherByNameAsync("MercadoAgroganadero"); await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
_lastDailyRun["MercadoAgroganadero"] = now.Date; _lastDailyRun["MercadoAgroganadero"] = nowInArgentina.Date;
} }
// Tarea 2: Granos BCR (todos los días a las 11:30) // --- Tarea 2: Granos BCR (L-V a las 11:30 AM) ---
if (now.Hour == 11 && now.Minute == 30 && HasNotRunToday("BCR")) if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 30 && HasNotRunToday("BCR"))
{ {
await RunFetcherByNameAsync("BCR"); await RunFetcherByNameAsync("BCR", stoppingToken);
_lastDailyRun["BCR"] = now.Date; _lastDailyRun["BCR"] = nowInArgentina.Date;
} }
// Tarea 3: Mercados de Bolsa (cada 10 minutos si el mercado está abierto) // --- Tarea 3 y 4: Mercados de Bolsa (L-V, durante horario de mercado, una vez por hora) ---
if (IsMarketOpen(now) && now.Minute % 10 == 0) // Se ejecutan si el mercado está abierto y si el minuto actual es exactamente 10.
// Esto replica la lógica de "cada hora a las y 10".
if (IsArgentineMarketOpen(nowInArgentina) && nowInArgentina.Minute == 10)
{ {
_logger.LogInformation("Mercados abiertos. Ejecutando fetchers de bolsa."); _logger.LogInformation("Hora de actualización de mercados de bolsa. Ejecutando fetchers...");
await RunFetcherByNameAsync("Finnhub");
await RunFetcherByNameAsync("YahooFinance"); await RunFetcherByNameAsync("YahooFinance", stoppingToken);
// Si Finnhub está desactivado en Program.cs, este simplemente no se encontrará y se omitirá.
await RunFetcherByNameAsync("Finnhub", stoppingToken);
} }
} }
// Esta función crea un "scope" para ejecutar un fetcher específico. /// <summary>
// Esto es crucial para que la inyección de dependencias funcione correctamente. /// Ejecuta un fetcher específico por su nombre. Utiliza un scope de DI para gestionar
private async Task RunFetcherByNameAsync(string sourceName) /// correctamente el ciclo de vida de los servicios (como las conexiones a la BD).
/// </summary>
private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken)
{ {
if (stoppingToken.IsCancellationRequested) return;
_logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName);
// Crea un "scope" de servicios. Todos los servicios "scoped" (como los repositorios)
// se crearán de nuevo para esta ejecución y se desecharán al final, evitando problemas.
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>();
var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase));
@@ -84,32 +123,42 @@ namespace Mercados.Worker
} }
} }
// Función de ayuda para ejecutar todos los fetchers (usada al inicio). /// <summary>
private async Task RunAllFetchersAsync() /// Ejecuta todos los fetchers al iniciar el servicio. Esto es útil para poblar
/// la base de datos inmediatamente al arrancar el worker.
/// </summary>
/*
private async Task RunAllFetchersAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("Ejecutando todos los fetchers al iniciar..."); _logger.LogInformation("Ejecutando todos los fetchers al iniciar...");
using var scope = _serviceProvider.CreateScope(); using var scope = _serviceProvider.CreateScope();
var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>(); var fetchers = scope.ServiceProvider.GetRequiredService<IEnumerable<IDataFetcher>>();
foreach (var fetcher in fetchers) foreach (var fetcher in fetchers)
{ {
await RunFetcherByNameAsync(fetcher.SourceName); if (stoppingToken.IsCancellationRequested) break;
await RunFetcherByNameAsync(fetcher.SourceName, stoppingToken);
} }
} }
*/
#region Funciones de Ayuda para la Planificación #region Funciones de Ayuda para la Planificación
private bool HasNotRunToday(string taskName) private bool HasNotRunToday(string taskName)
{ {
return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < DateTime.Now.Date; // Comprueba si la tarea ya se ejecutó en la fecha actual (en zona horaria de Argentina).
return !_lastDailyRun.ContainsKey(taskName) || _lastDailyRun[taskName].Date < TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone).Date;
} }
private bool IsMarketOpen(DateTime now) private bool IsWeekDay(DateTime now)
{ {
// Lunes a Viernes (1 a 5, Domingo es 0) return now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday;
if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday) }
return false;
private bool IsArgentineMarketOpen(DateTime now)
{
if (!IsWeekDay(now)) return false;
// Horario de mercado de 11:00 a 17:15 (hora de Argentina) // Rango de 11:00 a 17:15, para asegurar la captura del cierre a las 17:10.
var marketOpen = new TimeSpan(11, 0, 0); var marketOpen = new TimeSpan(11, 0, 0);
var marketClose = new TimeSpan(17, 15, 0); var marketClose = new TimeSpan(17, 15, 0);

View File

@@ -8,6 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.5" />
</ItemGroup> </ItemGroup>

View File

@@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence;
using Mercados.Infrastructure.Persistence.Repositories; using Mercados.Infrastructure.Persistence.Repositories;
using Mercados.Worker; using Mercados.Worker;
// Carga las variables de entorno desde el archivo .env en la raíz de la solución.
DotNetEnv.Env.Load();
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// --- Configuración del Host --- // --- Configuración del Host ---
// Esto prepara el host del servicio, permitiendo la inyección de dependencias, // Esto prepara el host del servicio, permitiendo la inyección de dependencias,

View File

@@ -7,13 +7,13 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" "DefaultConnection": ""
}, },
"ApiKeys": { "ApiKeys": {
"Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", "Finnhub": "",
"Bcr": { "Bcr": {
"Key": "D1782A51-A5FD-EF11-9445-00155D09E201", "Key": "",
"Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" "Secret": ""
} }
} }
} }