From 93b2887bd5c89c661b891aaf19298134993ed47a Mon Sep 17 00:00:00 2001 From: dmolinari Date: Thu, 3 Jul 2025 11:44:10 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20adaptaci=C3=B3n=20de=20los=20proyectos?= =?UTF-8?q?=20para=20utilizar=20.env=20y=20comienzo=20de=20preparaci=C3=B3?= =?UTF-8?q?n=20para=20despliegue=20en=20docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 7 + frontend/index.html | 58 ++++- frontend/package-lock.json | 72 +++++++ frontend/package.json | 2 + frontend/src/App.tsx | 8 +- .../components/AgroHistoricalChartWidget.tsx | 44 ++++ frontend/src/components/BolsaLocalWidget.tsx | 121 ++++++----- frontend/src/components/BolsaUsaWidget.tsx | 112 +++++----- .../GrainsHistoricalChartWidget.tsx | 43 ++++ frontend/src/components/GranosCardWidget.tsx | 198 ++++++++++++------ frontend/src/components/GranosWidget.tsx | 2 +- .../src/components/HistoricalChartWidget.tsx | 9 +- .../src/components/MercadoAgroCardWidget.tsx | 139 ++++++++---- frontend/src/components/MercadoAgroWidget.tsx | 142 ++++++++----- frontend/src/components/MervalHeroCard.tsx | 77 +++++++ frontend/src/components/UsaIndexHeroCard.tsx | 73 +++++++ .../src/components/raw-data/RawAgroTable.tsx | 85 ++++++++ .../raw-data/RawBolsaLocalTable.tsx | 78 +++++++ .../components/raw-data/RawBolsaUsaTable.tsx | 76 +++++++ .../components/raw-data/RawGranosTable.tsx | 73 +++++++ frontend/src/main.tsx | 74 ++++++- frontend/src/models/mercadoModels.ts | 1 + frontend/src/pages/RawDataPage.tsx | 22 ++ frontend/src/pages/RawDataView.tsx | 22 ++ frontend/src/utils/clipboardUtils.ts | 34 +++ frontend/src/utils/formatters.ts | 2 +- frontend/vite.config.ts | 30 ++- .../Controllers/MercadosController.cs | 34 +++ src/Mercados.Api/Mercados.Api.csproj | 1 + src/Mercados.Api/Program.cs | 8 +- src/Mercados.Api/appsettings.json | 8 +- src/Mercados.Core/Entities/CotizacionBolsa.cs | 1 + src/Mercados.Database/Class1.cs | 6 - .../20240702133000_AddNameToStocks.cs | 19 ++ src/Mercados.Infrastructure/Class1.cs | 6 - .../DataFetchers/BcrDataFetcher.cs | 4 +- .../DataFetchers/FinnhubDataFetcher.cs | 13 +- .../DataFetchers/TickerNameMapping.cs | 61 ++++++ .../DataFetchers/YahooFinanceDataFetcher.cs | 9 +- .../Repositories/CotizacionBolsaRepository.cs | 15 +- .../CotizacionGanadoRepository.cs | 23 ++ .../Repositories/CotizacionGranoRepository.cs | 18 ++ .../ICotizacionGanadoRepository.cs | 1 + .../ICotizacionGranoRepository.cs | 1 + .../Persistence/SqlConnectionFactory.cs | 5 +- src/Mercados.Worker/DataFetchingService.cs | 117 ++++++++--- src/Mercados.Worker/Mercados.Worker.csproj | 1 + src/Mercados.Worker/Program.cs | 3 + src/Mercados.Worker/appsettings.json | 8 +- 49 files changed, 1610 insertions(+), 356 deletions(-) create mode 100644 .env create mode 100644 frontend/src/components/AgroHistoricalChartWidget.tsx create mode 100644 frontend/src/components/GrainsHistoricalChartWidget.tsx create mode 100644 frontend/src/components/MervalHeroCard.tsx create mode 100644 frontend/src/components/UsaIndexHeroCard.tsx create mode 100644 frontend/src/components/raw-data/RawAgroTable.tsx create mode 100644 frontend/src/components/raw-data/RawBolsaLocalTable.tsx create mode 100644 frontend/src/components/raw-data/RawBolsaUsaTable.tsx create mode 100644 frontend/src/components/raw-data/RawGranosTable.tsx create mode 100644 frontend/src/pages/RawDataPage.tsx create mode 100644 frontend/src/pages/RawDataView.tsx create mode 100644 frontend/src/utils/clipboardUtils.ts delete mode 100644 src/Mercados.Database/Class1.cs create mode 100644 src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs delete mode 100644 src/Mercados.Infrastructure/Class1.cs create mode 100644 src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs diff --git a/.env b/.env new file mode 100644 index 0000000..4287c9c --- /dev/null +++ b/.env @@ -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" \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 7dc52fb..facbc20 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,63 @@ - + - Mercadors - El Día + Mercados Widgets - Catálogo de Componentes + -
+

Catálogo Completo de Widgets de Mercados

+

Esta página muestra todos los componentes disponibles para ser integrados.

+ +
+ +

Sección: Mercado Local

+

Componente: Tarjeta de Héroe (Merval)

+
+
+

Componente: Tabla de Acciones Líderes

+
+ +
+ +

Sección: Mercado de EEUU

+

Componente: Tarjeta de Héroe (S&P 500)

+
+
+

Componente: Tabla de Acciones de EEUU

+
+ +
+ +

Sección: Granos

+

Componente: Tarjetas de Resumen

+
+
+

Componente: Tabla Detallada

+
+ +
+ +

Sección: Mercado Agroganadero

+

Componente: Tarjetas de Resumen

+
+
+

Componente: Tabla/Lista Responsiva Detallada

+
+ +
+ +

Sección: Página para Redacción (embebida)

+
+ + + - + \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c849938..f056aff 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,10 +17,12 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.6.3", "recharts": "^3.0.2" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@types/node": "^24.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", @@ -1901,6 +1903,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.0.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz", + "integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -2522,6 +2534,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -4090,6 +4111,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.3.tgz", + "integrity": "sha512-zf45LZp5skDC6I3jDLXQUu0u26jtuP4lEGbc7BbdyxenBN1vJSTA18czM2D+h5qyMBuMrD+9uB+mU37HIoKGRA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.3.tgz", + "integrity": "sha512-DiWJm9qdUAmiJrVWaeJdu4TKu13+iB/8IEi0EW/XgaHCjW/vWGrwzup0GVvaMteuZjKnh5bEvJP/K0MDnzawHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.6.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -4274,6 +4333,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4487,6 +4552,13 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index b703220..83a7c97 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,10 +19,12 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-icons": "^5.5.0", + "react-router-dom": "^7.6.3", "recharts": "^3.0.2" }, "devDependencies": { "@eslint/js": "^9.29.0", + "@types/node": "^24.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.5.2", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb8f687..68ca4a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,11 +24,11 @@ function App() { Datos del Mercado - {/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */} + {/* --- Sección 1: Mercado Agroganadero de Cañuelas --- Mercado Agroganadero de Cañuelas - + */} {/* --- Sección 1: Mercado Agroganadero de Cañuelas --- */} @@ -50,11 +50,11 @@ function App() { - {/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- */} + {/* --- Sección 2: Granos - Bolsa de Comercio de Rosario --- Granos - Bolsa de Comercio de Rosario - + */} {/* --- Sección 3: Mercado de Valores de Estados Unidos --- */} diff --git a/frontend/src/components/AgroHistoricalChartWidget.tsx b/frontend/src/components/AgroHistoricalChartWidget.tsx new file mode 100644 index 0000000..f7a3816 --- /dev/null +++ b/frontend/src/components/AgroHistoricalChartWidget.tsx @@ -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(apiUrl); + + if (loading) { + return ; + } + + if (error) { + return {error}; + } + + if (!data || data.length < 2) { + return No hay suficientes datos históricos para graficar esta categoría.; + } + + return ( + + + + + `$${tick.toLocaleString('es-AR')}`} /> + [`$${value.toFixed(2)}`, 'Precio Promedio']} /> + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/BolsaLocalWidget.tsx b/frontend/src/components/BolsaLocalWidget.tsx index a9a8e3e..0714ff9 100644 --- a/frontend/src/components/BolsaLocalWidget.tsx +++ b/frontend/src/components/BolsaLocalWidget.tsx @@ -8,12 +8,11 @@ import CloseIcon from '@mui/icons-material/Close'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import RemoveIcon from '@mui/icons-material/Remove'; -import { formatFullDateTime } from '../utils/formatters'; +import { formatFullDateTime, formatCurrency } from '../utils/formatters'; import type { CotizacionBolsa } from '../models/mercadoModels'; import { useApiData } from '../hooks/useApiData'; import { HistoricalChartWidget } from './HistoricalChartWidget'; - -const formatNumber = (num: number) => new Intl.NumberFormat('es-AR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); +import { PiChartLineUpBold } from 'react-icons/pi'; const Variacion = ({ value }: { value: number }) => { const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; @@ -21,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => { return ( - {formatNumber(value)}% + {value.toFixed(2)}% ); }; @@ -30,13 +29,10 @@ export const BolsaLocalWidget = () => { const { data, loading, error } = useApiData('/mercados/bolsa/local'); const [selectedTicker, setSelectedTicker] = useState(null); - const handleRowClick = (ticker: string) => { - setSelectedTicker(ticker); - }; + const handleRowClick = (ticker: string) => setSelectedTicker(ticker); + const handleCloseDialog = () => setSelectedTicker(null); - const handleCloseDialog = () => { - setSelectedTicker(null); - }; + const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || []; if (loading) { return ; @@ -47,54 +43,75 @@ export const BolsaLocalWidget = () => { } if (!data || data.length === 0) { - return No hay datos disponibles para el mercado local en este momento.; + return No hay datos disponibles para el mercado local.; } return ( <> - - - - Última actualización: {formatFullDateTime(data[0].fechaRegistro)} - - - - - - Símbolo - Precio Actual - Apertura - Cierre Anterior - % Cambio - - - - {data.map((row) => ( - handleRowClick(row.ticker)}> - {row.ticker} - ${formatNumber(row.precioActual)} - ${formatNumber(row.apertura)} - ${formatNumber(row.cierreAnterior)} - - - ))} - -
-
- - - Historial de 30 días para: {selectedTicker} - theme.palette.grey[500] }} - > - - - + {panelPrincipal.length > 0 && ( + + + + Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)} + + + + + + Símbolo + Precio Actual + Apertura + Cierre Anterior + % Cambio + Historial + + + + {panelPrincipal.map((row) => ( + + {row.ticker} + ${formatCurrency(row.precioActual)} + ${formatCurrency(row.apertura)} + ${formatCurrency(row.cierreAnterior)} + + + 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)' } + }} + > + + + + + ))} + +
+
+ )} + + + theme.palette.grey[500], + backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, + }} + > + + + Historial de 30 días para: {selectedTicker} - {selectedTicker && } + {selectedTicker && } diff --git a/frontend/src/components/BolsaUsaWidget.tsx b/frontend/src/components/BolsaUsaWidget.tsx index c11ed61..38b1e36 100644 --- a/frontend/src/components/BolsaUsaWidget.tsx +++ b/frontend/src/components/BolsaUsaWidget.tsx @@ -8,14 +8,11 @@ import CloseIcon from '@mui/icons-material/Close'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import RemoveIcon from '@mui/icons-material/Remove'; -import { formatFullDateTime } from '../utils/formatters'; +import { formatFullDateTime, formatCurrency } from '../utils/formatters'; import type { CotizacionBolsa } from '../models/mercadoModels'; import { useApiData } from '../hooks/useApiData'; import { HistoricalChartWidget } from './HistoricalChartWidget'; - -// Usamos el formato de EEUU para los precios en dólares -const formatCurrency = (num: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num); -const formatPercentage = (num: number) => num.toFixed(2); +import { PiChartLineUpBold } from 'react-icons/pi'; const Variacion = ({ value }: { value: number }) => { const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; @@ -23,7 +20,7 @@ const Variacion = ({ value }: { value: number }) => { return ( - {formatPercentage(value)}% + {value.toFixed(2)}% ); }; @@ -32,13 +29,10 @@ export const BolsaUsaWidget = () => { const { data, loading, error } = useApiData('/mercados/bolsa/eeuu'); const [selectedTicker, setSelectedTicker] = useState(null); - const handleRowClick = (ticker: string) => { - setSelectedTicker(ticker); - }; + const handleRowClick = (ticker: string) => setSelectedTicker(ticker); + const handleCloseDialog = () => setSelectedTicker(null); - const handleCloseDialog = () => { - setSelectedTicker(null); - }; + const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || []; if (loading) { return ; @@ -48,56 +42,64 @@ export const BolsaUsaWidget = () => { return {error}; } - // Recordatorio de que el fetcher puede estar desactivado if (!data || data.length === 0) { - return No hay datos disponibles para el mercado de EEUU. (El fetcher podría estar desactivado en el Worker).; + return No hay datos disponibles para el mercado de EEUU.; } return ( <> - - - - Última actualización: {formatFullDateTime(data[0].fechaRegistro)} - - - - - - Símbolo - Precio Actual - Apertura - Cierre Anterior - % Cambio - - - - {data.map((row) => ( - handleRowClick(row.ticker)}> - {row.ticker} - {formatCurrency(row.precioActual)} - {formatCurrency(row.apertura)} - {formatCurrency(row.cierreAnterior)} - - - ))} - -
-
+ {/* Renderizamos la tabla solo si hay otras acciones */} + {otherStocks.length > 0 && ( + + + + Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)} + + + + + + Símbolo + Precio Actual + + Apertura + Cierre Anterior - - - Historial de 30 días para: {selectedTicker} - theme.palette.grey[500] }} - > - - - + % Cambio + Historial + + + + {otherStocks.map((row) => ( + + {row.ticker} + {formatCurrency(row.precioActual, 'USD')} + + {formatCurrency(row.apertura, 'USD')} + {formatCurrency(row.cierreAnterior, 'USD')} + + + + 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)' } }} + > + + + ))} + +
+
+ )} + + + theme.palette.grey[500], backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, }}> + + + Historial de 30 días para: {selectedTicker} - {selectedTicker && } + {selectedTicker && } diff --git a/frontend/src/components/GrainsHistoricalChartWidget.tsx b/frontend/src/components/GrainsHistoricalChartWidget.tsx new file mode 100644 index 0000000..c81d142 --- /dev/null +++ b/frontend/src/components/GrainsHistoricalChartWidget.tsx @@ -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(apiUrl); + + if (loading) { + return ; + } + + if (error) { + return {error}; + } + + if (!data || data.length < 2) { + return No hay suficientes datos históricos para graficar este grano.; + } + + return ( + + + + + `$${tick.toLocaleString('es-AR')}`} /> + [`$${value.toFixed(0)}`, 'Precio']} /> + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/GranosCardWidget.tsx b/frontend/src/components/GranosCardWidget.tsx index cf532d5..bc06759 100644 --- a/frontend/src/components/GranosCardWidget.tsx +++ b/frontend/src/components/GranosCardWidget.tsx @@ -1,84 +1,113 @@ -import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material'; +import React, { useState, useRef } from 'react'; +import { + Box, CircularProgress, Alert, Paper, Typography, Dialog, + DialogTitle, DialogContent, IconButton +} from '@mui/material'; +import { PiChartLineUpBold } from "react-icons/pi"; +import CloseIcon from '@mui/icons-material/Close'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import RemoveIcon from '@mui/icons-material/Remove'; -// Iconos de react-icons para cada grano import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi"; import { TbGrain } from "react-icons/tb"; import type { CotizacionGrano } from '../models/mercadoModels'; import { useApiData } from '../hooks/useApiData'; -import { formatCurrency, formatDateOnly } from '../utils/formatters'; +import { formatInteger, formatDateOnly } from '../utils/formatters'; +import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; -// Función para elegir el icono según el nombre del grano const getGrainIcon = (nombre: string) => { - switch (nombre.toLowerCase()) { - case 'girasol': - return ; - case 'trigo': - return ; - case 'sorgo': - return ; - case 'maiz': - return ; - default: - return ; - } + switch (nombre.toLowerCase()) { + case 'girasol': return ; + case 'trigo': return ; + case 'sorgo': return ; + case 'maiz': return ; + default: return ; + } }; // Subcomponente para una única tarjeta de grano -const GranoCard = ({ grano }: { grano: CotizacionGrano }) => { - const isPositive = grano.variacionPrecio > 0; - const isNegative = grano.variacionPrecio < 0; - const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; - const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; +const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartClick: (event: React.MouseEvent) => void }) => { + const isPositive = grano.variacionPrecio > 0; + const isNegative = grano.variacionPrecio < 0; + const color = isPositive ? 'success.main' : isNegative ? 'error.main' : 'text.secondary'; + const Icon = isPositive ? ArrowUpwardIcon : isNegative ? ArrowDownwardIcon : RemoveIcon; + return ( + + + + + + {getGrainIcon(grano.nombre)} + + {grano.nombre} + + - return ( - - - {getGrainIcon(grano.nombre)} - - {grano.nombre} - - + + + ${formatInteger(grano.precio)} + + + por Tonelada + + - - - ${formatCurrency(grano.precio)} - - - por Tonelada - - - - - - - {formatCurrency(grano.variacionPrecio)} - - - - Operación: {formatDateOnly(grano.fechaOperacion)} - - - ); + + + + {formatInteger(grano.variacionPrecio)} + + + + Operación: {formatDateOnly(grano.fechaOperacion)} + + + ); }; export const GranosCardWidget = () => { const { data, loading, error } = useApiData('/mercados/granos'); + const [selectedGrano, setSelectedGrano] = useState(null); + const triggerButtonRef = useRef(null); + + const handleChartClick = (nombreGrano: string, event: React.MouseEvent) => { + triggerButtonRef.current = event.currentTarget; + setSelectedGrano(nombreGrano); + }; + + const handleCloseDialog = () => { + setSelectedGrano(null); + setTimeout(() => { + triggerButtonRef.current?.focus(); + }, 0); + }; if (loading) { return ; @@ -93,10 +122,47 @@ export const GranosCardWidget = () => { } return ( - + <> + {data.map((grano) => ( - - ))} - + handleChartClick(grano.nombre, event)} /> + ))} + + + 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 + }, + }} + > + + + Mensual de {selectedGrano} + + {selectedGrano && } + + + ); }; \ No newline at end of file diff --git a/frontend/src/components/GranosWidget.tsx b/frontend/src/components/GranosWidget.tsx index 0260ffa..f163cf8 100644 --- a/frontend/src/components/GranosWidget.tsx +++ b/frontend/src/components/GranosWidget.tsx @@ -10,7 +10,7 @@ import RemoveIcon from '@mui/icons-material/Remove'; const formatNumber = (num: number) => { return new Intl.NumberFormat('es-AR', { - minimumFractionDigits: 2, + minimumFractionDigits: 0, maximumFractionDigits: 2, }).format(num); }; diff --git a/frontend/src/components/HistoricalChartWidget.tsx b/frontend/src/components/HistoricalChartWidget.tsx index b59db4d..d3c87e1 100644 --- a/frontend/src/components/HistoricalChartWidget.tsx +++ b/frontend/src/components/HistoricalChartWidget.tsx @@ -6,6 +6,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi interface HistoricalChartWidgetProps { ticker: string; mercado: 'Local' | 'EEUU'; + dias: number; } // Formateador para el eje X (muestra DD/MM) @@ -14,9 +15,9 @@ const formatXAxis = (tickItem: string) => { return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); }; -export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidgetProps) => { - // Usamos el hook para obtener los datos del historial de los últimos 30 días - const { data, loading, error } = useApiData(`/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=30`); +export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => { + const apiUrl = `/mercados/bolsa/history/${ticker}?mercado=${mercado}&dias=${dias}`; + const { data, loading, error } = useApiData(apiUrl); if (loading) { return ; @@ -38,7 +39,7 @@ export const HistoricalChartWidget = ({ ticker, mercado }: HistoricalChartWidget `$${tick.toLocaleString('es-AR')}`} /> [`$${value.toFixed(2)}`, 'Precio']} /> - + ); diff --git a/frontend/src/components/MercadoAgroCardWidget.tsx b/frontend/src/components/MercadoAgroCardWidget.tsx index 9ed9402..f64656b 100644 --- a/frontend/src/components/MercadoAgroCardWidget.tsx +++ b/frontend/src/components/MercadoAgroCardWidget.tsx @@ -1,39 +1,75 @@ -import { Box, CircularProgress, Alert, Paper, Typography } from '@mui/material'; -import { PiCow } from "react-icons/pi"; // Un icono divertido para "cabezas" -import ScaleIcon from '@mui/icons-material/Scale'; // Para kilos +import { useState } from 'react'; +import { + Box, CircularProgress, Alert, Paper, Typography, Dialog, + DialogTitle, DialogContent, IconButton +} from '@mui/material'; +import { PiCow } from "react-icons/pi"; +import ScaleIcon from '@mui/icons-material/Scale'; +import { PiChartLineUpBold } from "react-icons/pi"; +import CloseIcon from '@mui/icons-material/Close'; import type { CotizacionGanado } from '../models/mercadoModels'; import { useApiData } from '../hooks/useApiData'; import { formatCurrency, formatInteger } from '../utils/formatters'; +import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget'; -const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => { +// El subcomponente ahora tendrá un botón para el gráfico. +const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => { return ( - - - {categoria.categoria} + // Añadimos posición relativa para poder posicionar el botón del gráfico. + + { + 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)', + } + }} + > + + + + + {registro.categoria} + {registro.especificaciones} + Precio Máximo: - ${formatCurrency(categoria.maximo)} + ${formatCurrency(registro.maximo)} Precio Mínimo: - ${formatCurrency(categoria.minimo)} + ${formatCurrency(registro.minimo)} Precio Mediano: - ${formatCurrency(categoria.mediano)} + ${formatCurrency(registro.mediano)} - + - - {formatInteger(categoria.cabezas)} + + {formatInteger(registro.cabezas)} Cabezas - + - {formatInteger(categoria.kilosTotales)} + {formatInteger(registro.kilosTotales)} Kilos @@ -41,9 +77,17 @@ const AgroCard = ({ categoria }: { categoria: CotizacionGanado }) => { ); }; -// Este widget agrupa los datos por categoría para un resumen más limpio. export const MercadoAgroCardWidget = () => { const { data, loading, error } = useApiData('/mercados/agroganadero'); + const [selectedCategory, setSelectedCategory] = useState(null); + + const handleChartClick = (registro: CotizacionGanado) => { + setSelectedCategory(registro); + }; + + const handleCloseDialog = () => { + setSelectedCategory(null); + }; if (loading) { return ; @@ -55,25 +99,50 @@ export const MercadoAgroCardWidget = () => { return No hay datos del mercado agroganadero disponibles.; } - // 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); - return ( - - {Object.values(resumenPorCategoria).map(categoria => ( - - ))} - + <> + + {data.map(registro => ( + handleChartClick(registro)} /> + ))} + + + 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 + }, + }} + > + + + + + Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) + + + {selectedCategory && ( + + )} + + + ); }; \ No newline at end of file diff --git a/frontend/src/components/MercadoAgroWidget.tsx b/frontend/src/components/MercadoAgroWidget.tsx index e63ea65..1c27d4a 100644 --- a/frontend/src/components/MercadoAgroWidget.tsx +++ b/frontend/src/components/MercadoAgroWidget.tsx @@ -4,66 +4,114 @@ import { } from '@mui/material'; import type { CotizacionGanado } from '../models/mercadoModels'; import { useApiData } from '../hooks/useApiData'; +import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters'; -const formatNumber = (num: number, fractionDigits = 2) => { - return new Intl.NumberFormat('es-AR', { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - }).format(num); +// --- V INICIO DE LA MODIFICACIÓN V --- +// El sub-componente ahora solo necesita renderizar la tarjeta de móvil. +// La fila de la tabla la haremos directamente en el componente principal. +const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { + const commonStyles = { + cell: { + display: 'flex', justifyContent: 'space-between', py: 0.5, px: 1, + borderBottom: '1px solid rgba(224, 224, 224, 1)', + }, + label: { fontWeight: 'bold', color: 'text.secondary' }, + value: { textAlign: 'right' } + }; + + return ( + + + Categoría: + {row.categoria} + + + Especificaciones: + {row.especificaciones} + + + Máximo: + ${formatCurrency(row.maximo)} + + + Mínimo: + ${formatCurrency(row.minimo)} + + + Mediano: + ${formatCurrency(row.mediano)} + + + Cabezas: + {formatInteger(row.cabezas)} + + + Kg Total: + {formatInteger(row.kilosTotales)} + + + Importe Total: + ${formatInteger(row.importeTotal)} + + + ); }; +// --- ^ FIN DE LA MODIFICACIÓN ^ --- + export const MercadoAgroWidget = () => { const { data, loading, error } = useApiData('/mercados/agroganadero'); - if (loading) { - return ; - } - - if (error) { - return {error}; - } - - if (!data || data.length === 0) { - return No hay datos del mercado agroganadero disponibles.; - } + if (loading) { return ; } + if (error) { return {error}; } + if (!data || data.length === 0) { return No hay datos del mercado agroganadero disponibles.; } return ( - - - - - Categoría - Especificaciones - Máximo - Mínimo - Mediano - Cabezas - Kilos Totales - Importe Total - - - - {data.map((row) => ( - - - {row.categoria} - - {row.especificaciones} - ${formatNumber(row.maximo)} - ${formatNumber(row.minimo)} - ${formatNumber(row.mediano)} - {formatNumber(row.cabezas, 0)} - {formatNumber(row.kilosTotales, 0)} - ${formatNumber(row.importeTotal)} + + {/* VISTA DE ESCRITORIO (se oculta en móvil) */} + +
+ + + Categoría + Especificaciones + Máximo + Mínimo + Mediano + Cabezas + Kg Total + Importe Total + + + {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)} + + ))} + +
+
+ + {/* VISTA DE MÓVIL (se oculta en escritorio) */} + + {data.map((row) => ( + ))} - - - + + + Fuente: Mercado Agroganadero S.A. - +
); }; \ No newline at end of file diff --git a/frontend/src/components/MervalHeroCard.tsx b/frontend/src/components/MervalHeroCard.tsx new file mode 100644 index 0000000..36fca17 --- /dev/null +++ b/frontend/src/components/MervalHeroCard.tsx @@ -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 ( + + + + + {formatCurrency(variacionPuntos)} + + + ({variacionPorcentaje.toFixed(2)}%) + + + + ); +}; +// --- ^ SUB-COMPONENTE AÑADIDO ^ --- + +export const MervalHeroCard = () => { + const { data: allLocalData, loading, error } = useApiData('/mercados/bolsa/local'); + const [dias, setDias] = useState(30); + + const handleRangoChange = ( _event: React.MouseEvent, nuevoRango: number | null ) => { + if (nuevoRango !== null) { setDias(nuevoRango); } + }; + + const mervalData = allLocalData?.find(d => d.ticker === '^MERV'); + + if (loading) { return ; } + if (error) { return {error}; } + if (!mervalData) { return No se encontraron datos para el índice MERVAL.; } + + return ( + + + + Índice S&P MERVAL + {formatInteger(mervalData.precioActual)} + + + {/* Ahora sí encontrará el componente */} + + + + + + + Semanal + Mensual + Anual + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/UsaIndexHeroCard.tsx b/frontend/src/components/UsaIndexHeroCard.tsx new file mode 100644 index 0000000..4a55b27 --- /dev/null +++ b/frontend/src/components/UsaIndexHeroCard.tsx @@ -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 ( + + + + + {variacionPuntos.toFixed(2)} + + + ({variacionPorcentaje.toFixed(2)}%) + + + + ); +}; + +export const UsaIndexHeroCard = () => { + const { data: allUsaData, loading, error } = useApiData('/mercados/bolsa/eeuu'); + const [dias, setDias] = useState(30); + + const handleRangoChange = ( _event: React.MouseEvent, nuevoRango: number | null ) => { + if (nuevoRango !== null) { setDias(nuevoRango); } + }; + + const indexData = allUsaData?.find(d => d.ticker === '^GSPC'); + + if (loading) { return ; } + if (error) { return {error}; } + if (!indexData) { return No se encontraron datos para el índice S&P 500.; } + + return ( + + + + S&P 500 Index + {formatInteger(indexData.precioActual)} + + + + + + + + + Semanal + Mensual + Anual + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/raw-data/RawAgroTable.tsx b/frontend/src/components/raw-data/RawAgroTable.tsx new file mode 100644 index 0000000..7cfd63d --- /dev/null +++ b/frontend/src/components/raw-data/RawAgroTable.tsx @@ -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('/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 ; + if (error) return {error}; + if (!data) return null; + + return ( + + + + + + + Categoría + Especificaciones + Máximo + Mínimo + Mediano + Cabezas + Kg Total + Importe Total + Última Act. + + + + {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)} + {formatFullDateTime(row.fechaRegistro)} + + ))} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/raw-data/RawBolsaLocalTable.tsx b/frontend/src/components/raw-data/RawBolsaLocalTable.tsx new file mode 100644 index 0000000..8d4ec32 --- /dev/null +++ b/frontend/src/components/raw-data/RawBolsaLocalTable.tsx @@ -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('/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 ; + if (error) return {error}; + if (!data) return null; + + return ( + + + + + + + Ticker + Nombre + Último Precio + Cierre Anterior + Variación % + Última Act. + + + + {data.map(row => ( + + {row.ticker} + {row.nombreEmpresa} + ${formatCurrency(row.precioActual)} + ${formatCurrency(row.cierreAnterior)} + {row.porcentajeCambio.toFixed(2)}% + {formatFullDateTime(row.fechaRegistro)} + + ))} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/raw-data/RawBolsaUsaTable.tsx b/frontend/src/components/raw-data/RawBolsaUsaTable.tsx new file mode 100644 index 0000000..2514887 --- /dev/null +++ b/frontend/src/components/raw-data/RawBolsaUsaTable.tsx @@ -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('/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 ; + if (error) return {error}; + if (!data || data.length === 0) return No hay datos disponibles (el fetcher puede estar desactivado).; + + return ( + + + + + + + Ticker + Nombre + Último Precio + Cierre Anterior + Variación % + Última Act. + + + + {data.map(row => ( + + {row.ticker} + {row.nombreEmpresa} + ${formatCurrency(row.precioActual, 'USD')} + ${formatCurrency(row.cierreAnterior, 'USD')} + {row.porcentajeCambio.toFixed(2)}% + {formatFullDateTime(row.fechaRegistro)} + + ))} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/raw-data/RawGranosTable.tsx b/frontend/src/components/raw-data/RawGranosTable.tsx new file mode 100644 index 0000000..84abc67 --- /dev/null +++ b/frontend/src/components/raw-data/RawGranosTable.tsx @@ -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('/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 ; + if (error) return {error}; + if (!data) return null; + + return ( + + + + + + + Grano + Precio ($/Tn) + Variación + Fecha Op. + Última Act. + + + + {data.map(row => ( + + {row.nombre} + ${formatInteger(row.precio)} + {formatInteger(row.variacionPrecio)} + {formatDateOnly(row.fechaOperacion)} + {formatFullDateTime(row.fechaRegistro)} + + ))} + +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4aff025..fbabc28 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,67 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import App from './App.tsx' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; -createRoot(document.getElementById('root')!).render( - - - , -) +// Importamos TODOS los widgets que queremos que estén disponibles +import { MercadoAgroWidget } from './components/MercadoAgroWidget'; +import { MercadoAgroCardWidget } from './components/MercadoAgroCardWidget'; +import { GranosWidget } from './components/GranosWidget'; +import { GranosCardWidget } from './components/GranosCardWidget'; +import { BolsaUsaWidget } from './components/BolsaUsaWidget'; +import { UsaIndexHeroCard } from './components/UsaIndexHeroCard'; +import { BolsaLocalWidget } from './components/BolsaLocalWidget'; +import { MervalHeroCard } from './components/MervalHeroCard'; +import { RawDataView } from './pages/RawDataView'; + +// El registro de todos nuestros widgets disponibles +const widgetRegistry = { + // Widgets del Mercado Local + 'merval-heroe': MervalHeroCard, + 'bolsa-local-tabla': BolsaLocalWidget, + + // Widgets del Mercado de EEUU + 'sp500-heroe': UsaIndexHeroCard, + 'bolsa-eeuu-tabla': BolsaUsaWidget, + + // Widgets de Granos + 'granos-tarjetas': GranosCardWidget, + 'granos-tabla': GranosWidget, + + // Widgets del Mercado Agroganadero + 'mercado-agro-tarjetas': MercadoAgroCardWidget, + 'mercado-agro-tabla': MercadoAgroWidget, + + // Página completa como un widget + 'pagina-datos-crudos': RawDataView, +}; + +// Tema base de Material-UI +const theme = createTheme(); + +// Exponemos la función de renderizado en el objeto window +// @ts-ignore +window.MercadosWidgets = { + render: () => { + const widgetContainers = document.querySelectorAll('[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( + + + + + + + ); + } + }); + } +}; + +// Si estamos en modo de desarrollo, llamamos a render automáticamente +if (import.meta.env.DEV) { + // @ts-ignore + window.MercadosWidgets.render(); +} \ No newline at end of file diff --git a/frontend/src/models/mercadoModels.ts b/frontend/src/models/mercadoModels.ts index 641159c..2a790d1 100644 --- a/frontend/src/models/mercadoModels.ts +++ b/frontend/src/models/mercadoModels.ts @@ -28,6 +28,7 @@ export interface CotizacionGrano { export interface CotizacionBolsa { id: number; ticker: string; + nombreEmpresa?: string; mercado: string; precioActual: number; apertura: number; diff --git a/frontend/src/pages/RawDataPage.tsx b/frontend/src/pages/RawDataPage.tsx new file mode 100644 index 0000000..f16f035 --- /dev/null +++ b/frontend/src/pages/RawDataPage.tsx @@ -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 ( + <> + + + + Datos para Redacción + + + + + {/* Reutilizamos el contenido */} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/RawDataView.tsx b/frontend/src/pages/RawDataView.tsx new file mode 100644 index 0000000..04760ab --- /dev/null +++ b/frontend/src/pages/RawDataView.tsx @@ -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 ( + + Mercado Agroganadero + + Granos + + Bolsa de Valores EEUU + + Bolsa de Valores Argentina + + + ); +}; \ No newline at end of file diff --git a/frontend/src/utils/clipboardUtils.ts b/frontend/src/utils/clipboardUtils.ts new file mode 100644 index 0000000..138c122 --- /dev/null +++ b/frontend/src/utils/clipboardUtils.ts @@ -0,0 +1,34 @@ +export const copyToClipboard = (text: string): Promise => { + // 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); + } + }); + } +}; \ No newline at end of file diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts index 288000c..1103340 100644 --- a/frontend/src/utils/formatters.ts +++ b/frontend/src/utils/formatters.ts @@ -6,7 +6,7 @@ export const formatCurrency = (num: number, currency = 'ARS') => { return new Intl.NumberFormat(locale, { style: style, currency: currency, - minimumFractionDigits: 2, + minimumFractionDigits: 0, maximumFractionDigits: 2, }).format(num); }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f77e6e9..5a98b85 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,11 +1,33 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' +import path from 'path'; // Importa el módulo 'path' de Node // https://vite.dev/config/ export default defineConfig({ plugins: [react()], - server: { - host: true, // o "0.0.0.0" - port: 5173 // el puerto que uses, opcional + // --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V --- + build: { + lib: { + // La entrada a nuestra librería. Apunta a nuestro main.tsx + entry: path.resolve(__dirname, 'src/main.tsx'), + // El nombre de la variable global que se expondrá + name: 'MercadosWidgets', + // El nombre del archivo de salida + fileName: (format) => `mercados-widgets.${format}.js`, + }, + // No necesitamos minificar el CSS si es simple, pero es buena práctica + cssCodeSplit: true, + // Generar un manifest para saber qué archivos se crearon + manifest: true, + rollupOptions: { + // Asegúrate de no externalizar React, para que se incluya en el bundle + external: [], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM' + } + } + } } -}) +}) \ No newline at end of file diff --git a/src/Mercados.Api/Controllers/MercadosController.cs b/src/Mercados.Api/Controllers/MercadosController.cs index a50519a..f657d09 100644 --- a/src/Mercados.Api/Controllers/MercadosController.cs +++ b/src/Mercados.Api/Controllers/MercadosController.cs @@ -113,5 +113,39 @@ namespace Mercados.Api.Controllers return StatusCode(500, "Ocurrió un error interno en el servidor."); } } + + [HttpGet("agroganadero/history")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task 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), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task 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."); + } + } } } \ No newline at end of file diff --git a/src/Mercados.Api/Mercados.Api.csproj b/src/Mercados.Api/Mercados.Api.csproj index d2cd28a..a586c48 100644 --- a/src/Mercados.Api/Mercados.Api.csproj +++ b/src/Mercados.Api/Mercados.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Mercados.Api/Program.cs b/src/Mercados.Api/Program.cs index 1813740..37cea6a 100644 --- a/src/Mercados.Api/Program.cs +++ b/src/Mercados.Api/Program.cs @@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence; using Mercados.Infrastructure.Persistence.Repositories; 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); // Nombre para política de CORS @@ -16,7 +19,10 @@ builder.Services.AddCors(options => options.AddPolicy(name: MyAllowSpecificOrigins, 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() .AllowAnyMethod(); }); diff --git a/src/Mercados.Api/appsettings.json b/src/Mercados.Api/appsettings.json index 5644844..6c56bd7 100644 --- a/src/Mercados.Api/appsettings.json +++ b/src/Mercados.Api/appsettings.json @@ -7,13 +7,13 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" + "DefaultConnection": "" }, "ApiKeys": { - "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", + "Finnhub": "", "Bcr": { - "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", - "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" + "Key": "", + "Secret": "" } } } \ No newline at end of file diff --git a/src/Mercados.Core/Entities/CotizacionBolsa.cs b/src/Mercados.Core/Entities/CotizacionBolsa.cs index 22eaf00..a8f0a82 100644 --- a/src/Mercados.Core/Entities/CotizacionBolsa.cs +++ b/src/Mercados.Core/Entities/CotizacionBolsa.cs @@ -4,6 +4,7 @@ namespace Mercados.Core.Entities { public long Id { get; set; } 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 decimal PrecioActual { get; set; } public decimal Apertura { get; set; } diff --git a/src/Mercados.Database/Class1.cs b/src/Mercados.Database/Class1.cs deleted file mode 100644 index da9d75c..0000000 --- a/src/Mercados.Database/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Mercados.Database; - -public class Class1 -{ - -} diff --git a/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs b/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs new file mode 100644 index 0000000..f5b3df5 --- /dev/null +++ b/src/Mercados.Database/Migrations/20240702133000_AddNameToStocks.cs @@ -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"); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Class1.cs b/src/Mercados.Infrastructure/Class1.cs deleted file mode 100644 index 4a2d363..0000000 --- a/src/Mercados.Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Mercados.Infrastructure; - -public class Class1 -{ - -} diff --git a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs index 393e087..7e435f6 100644 --- a/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/BcrDataFetcher.cs @@ -122,8 +122,8 @@ namespace Mercados.Infrastructure.DataFetchers private async Task GetAuthTokenAsync(HttpClient client) { var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); - request.Headers.Add("api_key", _configuration["ApiKeys:Bcr:Key"]); - request.Headers.Add("secret", _configuration["ApiKeys:Bcr:Secret"]); + request.Headers.Add("api_key", Environment.GetEnvironmentVariable("BCR_API_KEY")); + request.Headers.Add("secret", Environment.GetEnvironmentVariable("BCR_API_SECRET")); var response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); diff --git a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs index f5c3bcc..0a5b9f4 100644 --- a/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/FinnhubDataFetcher.cs @@ -11,9 +11,12 @@ namespace Mercados.Infrastructure.DataFetchers { public string SourceName => "Finnhub"; private readonly List _tickers = new() { - "AAPL", "AMD", "AMZN", "BRK-B", "KO", "MSFT", "NVDA", "GLD", - "XLF", "XLI", "XLE", "XLK", "YPF", "GGAL", "BMA", "TEO", - "PAM", "CEPU", "LOMA", "CRESY", "BBAR", "TGS", "EDN", "MELI", "GLOB" + // Tecnológicas y ETFs + "AAPL", "MSFT", "AMZN", "NVDA", "AMD", "KO", "BRK-B", "GLD", "XLF", "XLI", "XLE", "XLK", + // Empresas 'Latinas' en Wall Street + "MELI", "GLOB", + // ADRs Argentinos + "YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR" }; private readonly FinnhubClient _client; @@ -28,7 +31,7 @@ namespace Mercados.Infrastructure.DataFetchers IFuenteDatoRepository fuenteDatoRepository, ILogger logger) { - var apiKey = configuration["ApiKeys:Finnhub"]; + var apiKey = Environment.GetEnvironmentVariable("FINNHUB_API_KEY"); if (string.IsNullOrEmpty(apiKey)) { 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; var pctChange = ((quote.Current - quote.PreviousClose) / quote.PreviousClose) * 100; + cotizaciones.Add(new CotizacionBolsa { Ticker = ticker, + NombreEmpresa = TickerNameMapping.GetName(ticker), Mercado = "EEUU", PrecioActual = (decimal)quote.Current, Apertura = (decimal)quote.Open, diff --git a/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs b/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs new file mode 100644 index 0000000..a80c191 --- /dev/null +++ b/src/Mercados.Infrastructure/DataFetchers/TickerNameMapping.cs @@ -0,0 +1,61 @@ +namespace Mercados.Infrastructure.DataFetchers +{ + public static class TickerNameMapping + { + private static readonly Dictionary 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); + } + } +} \ No newline at end of file diff --git a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs index c177850..05bb29a 100644 --- a/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs +++ b/src/Mercados.Infrastructure/DataFetchers/YahooFinanceDataFetcher.cs @@ -9,9 +9,10 @@ namespace Mercados.Infrastructure.DataFetchers { public string SourceName => "YahooFinance"; private readonly List _tickers = new() { + "^GSPC", // Índice S&P 500 "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.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; @@ -33,7 +34,6 @@ namespace Mercados.Infrastructure.DataFetchers _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); 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 cotizaciones = new List(); @@ -41,10 +41,13 @@ namespace Mercados.Infrastructure.DataFetchers { if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; + string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; + cotizaciones.Add(new CotizacionBolsa { Ticker = sec.Symbol, - Mercado = "Local", + NombreEmpresa = TickerNameMapping.GetName(sec.Symbol), + Mercado = mercado, PrecioActual = (decimal)sec.RegularMarketPrice, Apertura = (decimal)sec.RegularMarketOpen, CierreAnterior = (decimal)sec.RegularMarketPreviousClose, diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs index a42e8e1..f453456 100644 --- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionBolsaRepository.cs @@ -18,8 +18,8 @@ namespace Mercados.Infrastructure.Persistence.Repositories using IDbConnection connection = _connectionFactory.CreateConnection(); const string sql = @" - INSERT INTO CotizacionesBolsa (Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro) - VALUES (@Ticker, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; + INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro) + VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; await connection.ExecuteAsync(sql, cotizaciones); } @@ -27,10 +27,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories public async Task> ObtenerUltimasPorMercadoAsync(string mercado) { 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 = @" WITH RankedCotizaciones AS ( SELECT @@ -42,7 +39,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories Mercado = @Mercado ) SELECT - Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro + Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro FROM RankedCotizaciones WHERE @@ -57,7 +54,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories const string sql = @" SELECT - Id, Ticker, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro + Id, Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro FROM CotizacionesBolsa WHERE @@ -65,7 +62,7 @@ namespace Mercados.Infrastructure.Persistence.Repositories AND Mercado = @Mercado AND FechaRegistro >= DATEADD(day, -@Dias, GETUTCDATE()) ORDER BY - FechaRegistro ASC;"; // ASC es importante para dibujar la línea del gráfico + FechaRegistro ASC;"; return await connection.QueryAsync(sql, new { Ticker = ticker, Mercado = mercado, Dias = dias }); } diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs index 17809c0..5257f69 100644 --- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGanadoRepository.cs @@ -43,5 +43,28 @@ namespace Mercados.Infrastructure.Persistence.Repositories return await connection.QueryAsync(sql); } + + public async Task> 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(sql, new { + Categoria = categoria, + Especificaciones = especificaciones, + Dias = dias + }); + } } } \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs index 5d6678c..bc68297 100644 --- a/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs +++ b/src/Mercados.Infrastructure/Persistence/Repositories/CotizacionGranoRepository.cs @@ -44,5 +44,23 @@ namespace Mercados.Infrastructure.Persistence.Repositories return await connection.QueryAsync(sql); } + + public async Task> 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(sql, new { Nombre = nombre, Dias = dias }); + } } } \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs index 122643f..21f6053 100644 --- a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs +++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGanadoRepository.cs @@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories { Task GuardarMuchosAsync(IEnumerable cotizaciones); Task> ObtenerUltimaTandaAsync(); + Task> ObtenerHistorialAsync(string categoria, string especificaciones, int dias); } } \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs index 84ca922..6b24bbe 100644 --- a/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs +++ b/src/Mercados.Infrastructure/Persistence/Repositories/ICotizacionGranoRepository.cs @@ -6,5 +6,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories { Task GuardarMuchosAsync(IEnumerable cotizaciones); Task> ObtenerUltimasAsync(); + Task> ObtenerHistorialAsync(string nombre, int dias); } } \ No newline at end of file diff --git a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs index 50d01bb..8343c4e 100644 --- a/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs +++ b/src/Mercados.Infrastructure/Persistence/SqlConnectionFactory.cs @@ -11,8 +11,9 @@ namespace Mercados.Infrastructure public SqlConnectionFactory(IConfiguration configuration) { - _connectionString = configuration.GetConnectionString("DefaultConnection") - ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); + // Leemos directamente de la variable de entorno + _connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING") + ?? throw new ArgumentNullException(nameof(configuration), "La variable de entorno 'DB_CONNECTION_STRING' no fue encontrada."); } public IDbConnection CreateConnection() diff --git a/src/Mercados.Worker/DataFetchingService.cs b/src/Mercados.Worker/DataFetchingService.cs index 9848bbd..2cac2d2 100644 --- a/src/Mercados.Worker/DataFetchingService.cs +++ b/src/Mercados.Worker/DataFetchingService.cs @@ -2,70 +2,109 @@ using Mercados.Infrastructure.DataFetchers; namespace Mercados.Worker { + /// + /// Servicio de fondo que orquesta la obtención de datos de diversas fuentes + /// de forma programada y periódica. + /// public class DataFetchingService : BackgroundService { private readonly ILogger _logger; 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 _lastDailyRun = new(); public DataFetchingService(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _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"); + } } + /// + /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca. + /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); - // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. - await RunAllFetchersAsync(); + // Se recomienda una ejecución inicial para poblar la base de datos inmediatamente + // 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)); - 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() + /// + /// Revisa la hora actual y ejecuta las tareas que coincidan con su horario programado. + /// + private async Task RunScheduledTasksAsync(CancellationToken stoppingToken) { - // --- Lógica de Planificación --- - var now = DateTime.Now; + // Se obtiene la hora actual convertida a la zona horaria de Argentina. + var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone); - // Tarea 1: Mercado Agroganadero (todos los días a las 11:00) - if (now.Hour == 11 && now.Minute == 0 && HasNotRunToday("MercadoAgroganadero")) + // --- Tarea 1: Mercado Agroganadero (L-V a las 11:00 AM) --- + if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 0 && HasNotRunToday("MercadoAgroganadero")) { - await RunFetcherByNameAsync("MercadoAgroganadero"); - _lastDailyRun["MercadoAgroganadero"] = now.Date; + await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); + _lastDailyRun["MercadoAgroganadero"] = nowInArgentina.Date; } - // Tarea 2: Granos BCR (todos los días a las 11:30) - if (now.Hour == 11 && now.Minute == 30 && HasNotRunToday("BCR")) + // --- Tarea 2: Granos BCR (L-V a las 11:30 AM) --- + if (IsWeekDay(nowInArgentina) && nowInArgentina.Hour == 11 && nowInArgentina.Minute == 30 && HasNotRunToday("BCR")) { - await RunFetcherByNameAsync("BCR"); - _lastDailyRun["BCR"] = now.Date; + await RunFetcherByNameAsync("BCR", stoppingToken); + _lastDailyRun["BCR"] = nowInArgentina.Date; } - // Tarea 3: Mercados de Bolsa (cada 10 minutos si el mercado está abierto) - if (IsMarketOpen(now) && now.Minute % 10 == 0) + // --- Tarea 3 y 4: Mercados de Bolsa (L-V, durante horario de mercado, una vez por hora) --- + // 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."); - await RunFetcherByNameAsync("Finnhub"); - await RunFetcherByNameAsync("YahooFinance"); + _logger.LogInformation("Hora de actualización de mercados de bolsa. Ejecutando fetchers..."); + + 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. - // Esto es crucial para que la inyección de dependencias funcione correctamente. - private async Task RunFetcherByNameAsync(string sourceName) + /// + /// Ejecuta un fetcher específico por su nombre. Utiliza un scope de DI para gestionar + /// correctamente el ciclo de vida de los servicios (como las conexiones a la BD). + /// + private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) { + if (stoppingToken.IsCancellationRequested) return; + _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(); var fetchers = scope.ServiceProvider.GetRequiredService>(); 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). - 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. + /// + /* + private async Task RunAllFetchersAsync(CancellationToken stoppingToken) { _logger.LogInformation("Ejecutando todos los fetchers al iniciar..."); using var scope = _serviceProvider.CreateScope(); var fetchers = scope.ServiceProvider.GetRequiredService>(); 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 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) - if (now.DayOfWeek == DayOfWeek.Saturday || now.DayOfWeek == DayOfWeek.Sunday) - return false; + return now.DayOfWeek >= DayOfWeek.Monday && now.DayOfWeek <= DayOfWeek.Friday; + } + + 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 marketClose = new TimeSpan(17, 15, 0); diff --git a/src/Mercados.Worker/Mercados.Worker.csproj b/src/Mercados.Worker/Mercados.Worker.csproj index 5648ea4..25c5b8d 100644 --- a/src/Mercados.Worker/Mercados.Worker.csproj +++ b/src/Mercados.Worker/Mercados.Worker.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Mercados.Worker/Program.cs b/src/Mercados.Worker/Program.cs index 4e5af0a..745e9d8 100644 --- a/src/Mercados.Worker/Program.cs +++ b/src/Mercados.Worker/Program.cs @@ -5,6 +5,9 @@ using Mercados.Infrastructure.Persistence; using Mercados.Infrastructure.Persistence.Repositories; 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); // --- Configuración del Host --- // Esto prepara el host del servicio, permitiendo la inyección de dependencias, diff --git a/src/Mercados.Worker/appsettings.json b/src/Mercados.Worker/appsettings.json index 5644844..6c56bd7 100644 --- a/src/Mercados.Worker/appsettings.json +++ b/src/Mercados.Worker/appsettings.json @@ -7,13 +7,13 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "DefaultConnection": "Server=TECNICA3;Database=MercadosDb;User Id=mercadosuser;Password=@mercados1351@;Trusted_Connection=False;Encrypt=False;" + "DefaultConnection": "" }, "ApiKeys": { - "Finnhub": "cuvhr0hr01qs9e81st2gcuvhr0hr01qs9e81st30", + "Finnhub": "", "Bcr": { - "Key": "D1782A51-A5FD-EF11-9445-00155D09E201", - "Secret": "da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" + "Key": "", + "Secret": "" } } } \ No newline at end of file