Compare commits
49 Commits
5e317ab304
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 363f71282f | |||
| fb872f0889 | |||
| fbe07b7ea2 | |||
| 2a59edf050 | |||
| 7c5d66665e | |||
| 697f093ef1 | |||
| 5ebf4a4320 | |||
| 67a2f3f449 | |||
| efd0dfb574 | |||
| c10924bc35 | |||
| 24a6fc3849 | |||
| 134c9399e9 | |||
| 9dbf8dbe7e | |||
| c778816efd | |||
| 681809387e | |||
| aadd0b218b | |||
| f087799191 | |||
| e9540fa155 | |||
| 23f0d02fe3 | |||
| 8878ec632e | |||
| e3339fff99 | |||
| 55457afcac | |||
| 2da9e17067 | |||
| 191a49977a | |||
| e1e23f5315 | |||
| 640b7d1ece | |||
| cda2726960 | |||
| 2efc052755 | |||
| 30d03147c7 | |||
| 37bc4b0206 | |||
| e670ebaac7 | |||
| c9b3127f55 | |||
| 2cd57d0e60 | |||
| 3a50753c8a | |||
| d66765c646 | |||
| 761970a4de | |||
| c93ee2733b | |||
| c51be0433a | |||
| 9d4c19823c | |||
| 4f6e833a20 | |||
| 2a27207b41 | |||
| 2673539af1 | |||
| bb68cb9234 | |||
| 5286fa9617 | |||
| d411919288 | |||
| cd1cc283cd | |||
| 55b36b6042 | |||
| c63f53b69a | |||
| 32c99515dd |
34
.env
Normal file
34
.env
Normal file
@@ -0,0 +1,34 @@
|
||||
# =======================================================
|
||||
# == Fichero de Entorno para la Aplicación de Mercados ==
|
||||
# =======================================================
|
||||
# Este fichero debe estar en /opt/mercados-app/.env en el servidor.
|
||||
# Contiene todos los secretos y configuraciones específicas del entorno.
|
||||
|
||||
# --- Conexión a la Base de Datos ---
|
||||
# Cadena de conexión para SQL Server.
|
||||
# IMPORTANTE: El 'Server' debe ser el nombre del servicio de la base de datos en la red Docker compartida.
|
||||
# Según tu arquitectura, es 'db-sqlserver'.
|
||||
ConnectionStrings__DefaultConnection="Server=db-sqlserver;Database=MercadosDb;User ID=mercadosuser;Password=@mercados1351@;TrustServerCertificate=True;Encrypt=False;"
|
||||
|
||||
# --- Horarios de Ejecución del Worker (formato Cron) ---
|
||||
Schedules__MercadoAgroganadero="0 10,12,15,18 * * 1-5"
|
||||
Schedules__BCR="30 11 * * 1-5"
|
||||
Schedules__Bolsas="10 11-17 * * 1-5"
|
||||
|
||||
# --- Claves de APIs Externas ---
|
||||
ApiKeys__Finnhub="d1jsl99r01ql1h397eh0d1jsl99r01ql1h397ehg"
|
||||
ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201"
|
||||
ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3"
|
||||
|
||||
# --- Configuración de Email para Alertas (SMTP) ---
|
||||
SmtpSettings__Host="192.168.5.201"
|
||||
SmtpSettings__Port="587"
|
||||
SmtpSettings__User="alertas@eldia.com"
|
||||
SmtpSettings__Pass="@Alertas713550@"
|
||||
SmtpSettings__SenderName="Servicio de Mercados"
|
||||
SmtpSettings__Recipient="tecnica@eldia.com"
|
||||
|
||||
# --- Configuración del Entorno de ASP.NET Core ---
|
||||
# Esto asegura que la aplicación se ejecute en modo Producción,
|
||||
# desactivando páginas de error detalladas, etc.
|
||||
ASPNETCORE_ENVIRONMENT="Production"
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -178,6 +178,8 @@ DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
# DocFx
|
||||
[Dd]ocs/
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
@@ -414,3 +416,6 @@ FodyWeavers.xsd
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
.env
|
||||
.env
|
||||
.env
|
||||
|
||||
150
README.md
150
README.md
@@ -1,3 +1,149 @@
|
||||
# Mercados-Web
|
||||
---
|
||||
|
||||
API y Worker para la recolección y exposición de datos de mercados financieros.
|
||||
# Proyecto de Widgets de Mercados Financieros
|
||||
|
||||
Este repositorio contiene la solución completa para la recolección, almacenamiento y visualización de datos de mercados financieros. La arquitectura está diseñada para ser robusta, escalable y fácil de mantener, separando las responsabilidades en un backend de .NET, un worker de fondo, y un frontend de React basado en widgets.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Arquitectura General
|
||||
|
||||
El sistema se compone de los siguientes elementos principales:
|
||||
|
||||
1. **Backend (`Mercados.Api`):** Una API web de .NET 9 que expone los datos almacenados en la base de datos a través de endpoints RESTful.
|
||||
2. **Worker Service (`Mercados.Worker`):** Un servicio de fondo de .NET 9 responsable de obtener los datos de fuentes externas (`Finnhub`, `Bolsa de Comercio de Rosario`, `Yahoo Finance` y scraping) de forma programada, procesarlos y guardarlos en la base de datos.
|
||||
3. **Frontend (`frontend/`):** Una aplicación de React (construida con Vite + TypeScript) que proporciona un conjunto de "widgets" modulares y portátiles para visualizar los datos.
|
||||
4. **Base de Datos (`Mercados.Database`):** Utiliza SQL Server para la persistencia de los datos. El esquema es gestionado a través de migraciones con **FluentMigrator**.
|
||||
5. **Documentación(En Proceso) (`docs/`):** Un portal de documentación unificado generado con **DocFX**, **TypeDoc** y **Storybook**.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Configuración del Entorno de Desarrollo
|
||||
|
||||
Para ejecutar este proyecto en un entorno local, necesitarás tener instalado:
|
||||
|
||||
- **.NET 9 SDK**
|
||||
- **Node.js** (v20 o superior) y npm
|
||||
- **SQL Server** (Developer o Express Edition)
|
||||
- **Docker y Docker Compose** (Opcional, para simular el entorno de producción)
|
||||
- **DocFX Command Line Tool** (Instalar con: `dotnet tool install -g docfx`)
|
||||
|
||||
### Pasos de Configuración Inicial:
|
||||
|
||||
1. **Clonar el Repositorio:**
|
||||
```bash
|
||||
git clone [URL_DEL_REPOSITORIO_GITEA]
|
||||
cd [NOMBRE_DE_LA_CARPETA_DONDE_SE_CLONÓ]
|
||||
```
|
||||
|
||||
2. **Configurar Secretos del Backend:**
|
||||
Este proyecto utiliza "User Secrets" para manejar información sensible en desarrollo. Configúralos desde la raíz del repositorio:
|
||||
|
||||
```bash
|
||||
# Habilitar secretos para la API y el Worker
|
||||
dotnet user-secrets init --project src/Mercados.Api
|
||||
dotnet user-secrets init --project src/Mercados.Worker
|
||||
|
||||
# Establecer los valores (reemplaza con tus datos)
|
||||
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=...;Database=..." --project src/Mercados.Api
|
||||
dotnet user-secrets set "ApiKeys:Finnhub" "tu_api_key" --project src/Mercados.Api
|
||||
# ...añadir el resto de las claves para ApiKeys y SmtpSettings
|
||||
# Repite el proceso para el Worker
|
||||
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..." --project src/Mercados.Worker
|
||||
# ...
|
||||
```
|
||||
|
||||
3. **Instalar Dependencias del Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
4. **Crear y Migrar la Base de Datos:**
|
||||
Simplemente ejecuta la API una vez. La primera vez que se inicie, FluentMigrator creará la base de datos `MercadosDb` y aplicará todas las migraciones necesarias.
|
||||
```bash
|
||||
dotnet run --project src/Mercados.Api
|
||||
```
|
||||
Puedes detenerla con `Ctrl+C` una vez que veas el mensaje "Application started".
|
||||
|
||||
---
|
||||
|
||||
## 🏃 Cómo Ejecutar el Proyecto
|
||||
|
||||
Para trabajar en el proyecto, necesitarás ejecutar los 3 servicios principales en terminales separadas.
|
||||
|
||||
1. **Iniciar el Backend API:**
|
||||
```bash
|
||||
dotnet run --project src/Mercados.Api
|
||||
```
|
||||
*La API estará disponible (por defecto) en `http://localhost:5045`.*
|
||||
|
||||
2. **Iniciar el Worker Service:**
|
||||
```bash
|
||||
dotnet run --project src/Mercados.Worker
|
||||
```
|
||||
*El worker comenzará a obtener datos según los horarios definidos en `appsettings.json`.*
|
||||
|
||||
3. **Iniciar el Frontend (Servidor de Desarrollo):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
*La aplicación de React estará disponible en `http://localhost:5173`.*
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación
|
||||
|
||||
Este proyecto incluye un portal de documentación unificado que cubre tanto el backend como el frontend.
|
||||
|
||||
### Para Generar la Documentación:
|
||||
|
||||
1. **Compilar el Backend:**
|
||||
```bash
|
||||
# Desde la raíz
|
||||
dotnet build
|
||||
```
|
||||
2. **Generar la documentación TypeScript:**
|
||||
```bash
|
||||
# Desde la carpeta /frontend
|
||||
npm run doc:ts
|
||||
```
|
||||
3. **Construir el sitio DocFX:**
|
||||
```bash
|
||||
# Desde la raíz, usando el archivo de configuración en /docs
|
||||
docfx docs/docfx.json
|
||||
```
|
||||
4. **Visualizar la documentación:**
|
||||
```bash
|
||||
# Desde la raíz
|
||||
docfx serve docs/_site
|
||||
```
|
||||
*Abre tu navegador en `http://localhost:8080`.*
|
||||
|
||||
### Para ver la Librería de Componentes Visuales (Storybook):
|
||||
|
||||
```bash
|
||||
# Desde la carpeta /frontend
|
||||
npm run storybook
|
||||
```
|
||||
*Abre tu navegador en `http://localhost:6006`.*
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Despliegue en Producción (Docker)
|
||||
|
||||
El despliegue está diseñado para ser realizado con Docker y Docker Compose.
|
||||
|
||||
1. **Construir las imágenes de Docker:**
|
||||
```bash
|
||||
# Desde la raíz del proyecto
|
||||
docker compose build
|
||||
```
|
||||
2. **Lanzar el stack de servicios:**
|
||||
Asegúrate de tener el archivo `.env` configurado en el servidor de producción antes de ejecutar.
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
El `docker-compose.yml` está configurado para conectarse a una red externa `shared-net` para la base de datos y para ser gestionado por un proxy inverso externo como Nginx Proxy Manager.
|
||||
BIN
Widgets-ELDIA-1.1.pdf
Normal file
BIN
Widgets-ELDIA-1.1.pdf
Normal file
Binary file not shown.
22
docfx.build.json
Normal file
22
docfx.build.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"build": {
|
||||
"content": [
|
||||
{
|
||||
"files": [
|
||||
"docs/api/**.yml",
|
||||
"docs/toc.yml",
|
||||
"docs/index.md"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resource": [],
|
||||
"output": "docs/_site",
|
||||
"template": [
|
||||
"default",
|
||||
"modern"
|
||||
],
|
||||
"globalMetadata": {
|
||||
"_appTitle": "Documentación Proyecto Mercados"
|
||||
}
|
||||
}
|
||||
}
|
||||
19
docfx.metadata.json
Normal file
19
docfx.metadata.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"metadata": [
|
||||
{
|
||||
"src": [
|
||||
{
|
||||
"files": [
|
||||
"src/Mercados.Api/Mercados.Api.csproj",
|
||||
"src/Mercados.Core/Mercados.Core.csproj",
|
||||
"src/Mercados.Database/Mercados.Database.csproj",
|
||||
"src/Mercados.Infrastructure/Mercados.Infrastructure.csproj",
|
||||
"src/Mercados.Worker/Mercados.Worker.csproj"
|
||||
],
|
||||
"src": "."
|
||||
}
|
||||
],
|
||||
"dest": "docs/api"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
docker-compose.yml
Normal file
63
docker-compose.yml
Normal file
@@ -0,0 +1,63 @@
|
||||
services:
|
||||
# Servicio del Backend API
|
||||
mercados-api:
|
||||
build:
|
||||
context: ./Mercados-Web
|
||||
dockerfile: src/Mercados.Api/Dockerfile
|
||||
container_name: mercados-api
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env # Lee las variables desde un archivo .env en la misma carpeta
|
||||
networks:
|
||||
- mercados-net
|
||||
- shared-net # Se conecta a la red compartida para hablar con la DB
|
||||
# NO se exponen puertos al host.
|
||||
|
||||
# Servicio del Worker
|
||||
mercados-worker:
|
||||
build:
|
||||
context: ./Mercados-Web
|
||||
dockerfile: src/Mercados.Worker/Dockerfile
|
||||
container_name: mercados-worker
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- shared-net # Solo necesita acceso a la DB.
|
||||
# NO se exponen puertos al host.
|
||||
|
||||
# Servicio del Frontend (servido por Nginx)
|
||||
mercados-frontend:
|
||||
build:
|
||||
context: ./Mercados-Web/frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: mercados-frontend
|
||||
restart: always
|
||||
networks:
|
||||
- mercados-net
|
||||
# NO se exponen puertos al host.
|
||||
|
||||
# --- NUEVO SERVICIO: Proxy Inverso Local ---
|
||||
proxy:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: mercados-proxy
|
||||
restart: always
|
||||
volumes:
|
||||
# Mapeamos nuestro archivo de configuración al contenedor de Nginx
|
||||
- ./proxy-local/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
ports:
|
||||
# ESTE ES EL ÚNICO PUNTO DE ENTRADA DESDE EL EXTERIOR
|
||||
# Expone el puerto 80 del contenedor al puerto 8500 del host Debian.
|
||||
- "8500:80"
|
||||
networks:
|
||||
- mercados-net
|
||||
depends_on:
|
||||
- mercados-api
|
||||
- mercados-frontend
|
||||
|
||||
networks:
|
||||
mercados-net:
|
||||
driver: bridge
|
||||
|
||||
shared-net:
|
||||
external: true
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
# --- Etapa 1: Build ---
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Etapa 2: Producción ---
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
# Copiamos los archivos estáticos generados
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copiamos nuestra configuración personalizada de Nginx para el frontend
|
||||
COPY frontend.nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
31
frontend/frontend.nginx.conf
Normal file
31
frontend/frontend.nginx.conf
Normal file
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) ---
|
||||
# Se aplica EXCLUSIVAMENTE a la petición de /bootstrap.js
|
||||
location = /bootstrap.js {
|
||||
# Aseguramos que Nginx genere la huella digital ETag
|
||||
etag on;
|
||||
|
||||
# Instrucciones explícitas de no cachear
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, must-revalidate, private";
|
||||
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Bloque para otros activos con hash (con caché agresiva)
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Bloque para el enrutamiento de la aplicación React
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
87
frontend/public/bootstrap.js
vendored
Normal file
87
frontend/public/bootstrap.js
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
// frontend/public/bootstrap.js
|
||||
(function() {
|
||||
// El dominio donde se alojan los widgets
|
||||
const WIDGETS_HOST = 'https://widgets.eldia.com';
|
||||
|
||||
// Función para cargar dinámicamente un script
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'module';
|
||||
script.src = src;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Función para cargar dinámicamente una hoja de estilos
|
||||
function loadCSS(href) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Función principal
|
||||
async function initWidgets() {
|
||||
try {
|
||||
// 1. Obtener el manifest.json
|
||||
const response = await fetch(`${WIDGETS_HOST}/manifest.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error('No se pudo cargar el manifest de los widgets de mercados.');
|
||||
}
|
||||
const manifest = await response.json();
|
||||
|
||||
// 2. Encontrar el punto de entrada principal
|
||||
const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry);
|
||||
if (!entryKey) {
|
||||
throw new Error('No se encontró el punto de entrada en el manifest de mercados.');
|
||||
}
|
||||
|
||||
const entry = manifest[entryKey];
|
||||
const jsUrl = `${WIDGETS_HOST}/${entry.file}`;
|
||||
|
||||
// 3. Cargar el CSS si existe
|
||||
if (entry.css && entry.css.length > 0) {
|
||||
entry.css.forEach(cssFile => {
|
||||
const cssUrl = `${WIDGETS_HOST}/${cssFile}`;
|
||||
loadCSS(cssUrl);
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Cargar el JS principal
|
||||
await loadScript(jsUrl);
|
||||
|
||||
// 5. Una vez cargado, llamar a la función de renderizado para CADA WIDGET
|
||||
if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') {
|
||||
console.log('Bootstrap Mercados: La función render existe. Renderizando widgets...');
|
||||
|
||||
// Buscamos los contenedores específicos para los widgets de mercados
|
||||
const widgetContainers = document.querySelectorAll('[data-mercado-widget]');
|
||||
|
||||
if (widgetContainers.length === 0) {
|
||||
console.warn('Bootstrap Mercados: No se encontraron contenedores de widget en la página.');
|
||||
}
|
||||
|
||||
widgetContainers.forEach(container => {
|
||||
// Pasamos el contenedor y su dataset (props) a la función de renderizado
|
||||
window.MercadosWidgets.render(container, container.dataset);
|
||||
});
|
||||
} else {
|
||||
console.error('Bootstrap Mercados: La función window.MercadosWidgets.render no fue encontrada.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error al inicializar los widgets de mercados:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Esperamos a que el DOM esté listo para ejecutar
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWidgets);
|
||||
} else {
|
||||
initWidgets();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,11 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045).
|
||||
// En producción, esto debería apuntar a la URL real del servidor donde se despliegue la API.
|
||||
const API_BASE_URL = 'http://192.168.10.78:5045/api';
|
||||
|
||||
// Eliminamos la baseURL de aquí para evitar cualquier confusión.
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -2,15 +2,15 @@ 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';
|
||||
import { formatFullDateTime } from '../utils/formatters';
|
||||
|
||||
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' });
|
||||
const formatTooltipLabel = (label: string) => {
|
||||
return formatFullDateTime(label);
|
||||
};
|
||||
|
||||
export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => {
|
||||
@@ -29,13 +29,20 @@ export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroH
|
||||
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar esta categoría.</Alert>;
|
||||
}
|
||||
|
||||
const formatXAxis = (tickItem: string) => {
|
||||
return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
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']} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio Promedio']}
|
||||
labelFormatter={formatTooltipLabel}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
||||
@@ -8,112 +8,155 @@ 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, formatCurrency } from '../utils/formatters';
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { PiChartLineUpBold } from 'react-icons/pi';
|
||||
|
||||
// Importaciones de nuestro proyecto
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Sub-componente para mostrar la variación porcentual con un icono y color apropiado.
|
||||
*/
|
||||
const Variacion = ({ value }: { value: number }) => {
|
||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||
return (
|
||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
||||
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography>
|
||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tabla de acciones líderes locales (Panel Merval).
|
||||
*/
|
||||
export const BolsaLocalWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||
// Este widget obtiene todos los datos del mercado local y luego los filtra.
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
|
||||
const handleCloseDialog = () => setSelectedTicker(null);
|
||||
|
||||
const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButtonRef.current = event.currentTarget;
|
||||
setSelectedTicker(ticker);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setSelectedTicker(null);
|
||||
setTimeout(() => {
|
||||
triggerButtonRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Filtramos para obtener solo las acciones, excluyendo el índice.
|
||||
const panelPrincipal = data?.filter(d => d.ticker !== '^MERV') || [];
|
||||
|
||||
if (loading) {
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
|
||||
|
||||
// Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado.
|
||||
if (panelPrincipal.length === 0) {
|
||||
// Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante.
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay acciones líderes disponibles para mostrar.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
{panelPrincipal.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Box sx={{ p: 1, m: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Table size="small" aria-label="panel principal merval">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
||||
<TableCell align="center">% Cambio</TableCell>
|
||||
<TableCell align="center">Historial</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{panelPrincipal.map((row) => (
|
||||
<TableRow key={row.ticker} hover>
|
||||
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>${formatCurrency(row.apertura)}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>${formatCurrency(row.cierreAnterior)}</TableCell>
|
||||
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
aria-label={`ver historial de ${row.ticker}`}
|
||||
size="small"
|
||||
onClick={() => handleRowClick(row.ticker)}
|
||||
sx={{
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' }
|
||||
}}
|
||||
>
|
||||
<PiChartLineUpBold size="18" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box>
|
||||
{/* La alerta de feriado también se aplica a esta tabla. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Box sx={{ p: 1, m: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización de acciones: {formatFullDateTime(panelPrincipal[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Table size="small" aria-label="panel principal merval">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
||||
<TableCell align="center">% Cambio</TableCell>
|
||||
<TableCell align="center">Historial</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{panelPrincipal.map((row) => (
|
||||
<TableRow key={row.ticker} hover>
|
||||
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>${formatCurrency(row.apertura)}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>${formatCurrency(row.cierreAnterior)}</TableCell>
|
||||
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
aria-label={`ver historial de ${row.ticker}`}
|
||||
size="small"
|
||||
onClick={(event) => handleOpenModal(row.ticker, event)}
|
||||
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
|
||||
open={Boolean(selectedTicker)}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="close" onClick={handleCloseDialog}
|
||||
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' },
|
||||
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>
|
||||
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||
Historial de 30 días para: {selectedTicker}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer,
|
||||
TableHead, TableRow, Paper, Typography, Dialog, DialogTitle,
|
||||
@@ -8,100 +8,159 @@ 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, formatCurrency } from '../utils/formatters';
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { PiChartLineUpBold } from 'react-icons/pi';
|
||||
|
||||
// Importaciones de modelos, hooks y utilidades
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatFullDateTime, formatCurrency } from '../utils/formatters';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Sub-componente para mostrar la variación porcentual con un icono y color apropiado.
|
||||
*/
|
||||
const Variacion = ({ value }: { value: number }) => {
|
||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||
return (
|
||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
||||
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography>
|
||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tabla de acciones de EEUU y ADRs Argentinos.
|
||||
*/
|
||||
export const BolsaUsaWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
// Hooks para obtener los datos y el estado de feriado para el mercado de EEUU.
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
const isHoliday = useIsHoliday('US'); // <-- Usamos el código de mercado 'US'
|
||||
|
||||
// Estado y referencia para manejar el modal del gráfico.
|
||||
const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
|
||||
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleRowClick = (ticker: string) => setSelectedTicker(ticker);
|
||||
const handleCloseDialog = () => setSelectedTicker(null);
|
||||
|
||||
const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButtonRef.current = event.currentTarget;
|
||||
setSelectedTicker(ticker);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setSelectedTicker(null);
|
||||
// Devuelve el foco al botón que abrió el modal para mejorar la accesibilidad.
|
||||
setTimeout(() => {
|
||||
triggerButtonRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// Filtramos para obtener solo las acciones, excluyendo el índice S&P 500.
|
||||
const otherStocks = data?.filter(d => d.ticker !== '^GSPC') || [];
|
||||
|
||||
if (loading) {
|
||||
// Estado de carga unificado: el componente está "cargando" si los datos principales
|
||||
// o la información del feriado todavía no han llegado.
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>;
|
||||
|
||||
// Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado.
|
||||
if (otherStocks.length === 0) {
|
||||
// Si sabemos que es feriado, la alerta de feriado es el mensaje más relevante.
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay acciones de EEUU disponibles para mostrar.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Renderizamos la tabla solo si hay otras acciones */}
|
||||
{otherStocks.length > 0 && (
|
||||
<TableContainer component={Paper}>
|
||||
<Box sx={{ p: 1, m: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Table size="small" aria-label="panel principal eeuu">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
||||
|
||||
<TableCell align="center">% Cambio</TableCell>
|
||||
<TableCell align="center">Historial</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{otherStocks.map((row) => (
|
||||
<TableRow key={row.ticker} hover>
|
||||
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
||||
|
||||
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
aria-label={`ver historial de ${row.ticker}`} size="small"
|
||||
onClick={() => handleRowClick(row.ticker)}
|
||||
sx={{ boxShadow: '0 1px 3px rgba(0,0,0,0.1)', transition: 'all 0.2s ease-in-out', '&:hover': { transform: 'scale(1.1)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)' } }}
|
||||
><PiChartLineUpBold size="18" /></IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box>
|
||||
{/* Si es feriado, mostramos la alerta informativa en la parte superior. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<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' }, }}>
|
||||
<TableContainer component={Paper}>
|
||||
<Box sx={{ p: 1, m: 0 }}>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización de acciones: {formatFullDateTime(otherStocks[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Table size="small" aria-label="panel principal eeuu">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>Apertura</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>Cierre Anterior</TableCell>
|
||||
<TableCell align="center">% Cambio</TableCell>
|
||||
<TableCell align="center">Historial</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{otherStocks.map((row) => (
|
||||
<TableRow key={row.ticker} hover>
|
||||
<TableCell component="th" scope="row"><Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography></TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', md: 'table-cell' } }}>{formatCurrency(row.apertura, 'USD')}</TableCell>
|
||||
<TableCell align="right" sx={{ display: { xs: 'none', sm: 'table-cell' } }}>{formatCurrency(row.cierreAnterior, 'USD')}</TableCell>
|
||||
<TableCell align="center"><Variacion value={row.porcentajeCambio} /></TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton
|
||||
aria-label={`ver historial de ${row.ticker}`}
|
||||
size="small"
|
||||
onClick={(event) => handleOpenModal(row.ticker, event)}
|
||||
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>
|
||||
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||
Historial de 30 días para: {selectedTicker}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,16 @@ 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';
|
||||
import { formatFullDateTime } from '../utils/formatters';
|
||||
|
||||
interface GrainsHistoricalChartWidgetProps {
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
const formatTooltipLabel = (label: string) => {
|
||||
return formatFullDateTime(label);
|
||||
};
|
||||
|
||||
const formatXAxis = (tickItem: string) => {
|
||||
const date = new Date(tickItem);
|
||||
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||
@@ -32,9 +37,13 @@ export const GrainsHistoricalChartWidget = ({ nombre }: GrainsHistoricalChartWid
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
{/* Para los granos, usamos la fecha de operación en el eje X, que es más relevante */}
|
||||
<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']} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`$${value.toFixed(0)}`, 'Precio']}
|
||||
labelFormatter={formatTooltipLabel}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
|
||||
@@ -8,13 +8,17 @@ 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 { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi";
|
||||
import { GiSunflower, GiWheat, GiCorn, GiGrainBundle } from "react-icons/gi";
|
||||
import { TbGrain } from "react-icons/tb";
|
||||
|
||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { formatInteger, formatDateOnly } from '../utils/formatters';
|
||||
import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget';
|
||||
import { LuBean } from 'react-icons/lu';
|
||||
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
const getGrainIcon = (nombre: string) => {
|
||||
switch (nombre.toLowerCase()) {
|
||||
@@ -22,7 +26,8 @@ const getGrainIcon = (nombre: string) => {
|
||||
case 'trigo': return <GiWheat size={28} color="#fbc02d" />;
|
||||
case 'sorgo': return <TbGrain size={28} color="#fbc02d" />;
|
||||
case 'maiz': return <GiCorn size={28} color="#fbc02d" />;
|
||||
default: return <GiGrain size={28} color="#fbc02d" />;
|
||||
case 'soja': return <LuBean size={28} color="#fbc02d" />;
|
||||
default: return <GiGrainBundle size={28} color="#fbc02d" />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,7 +43,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli
|
||||
sx={{
|
||||
position: 'relative',
|
||||
p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
|
||||
flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '160px',
|
||||
flex: '1 1 180px', minWidth: '180px', maxWidth: '220px', height: '180px',
|
||||
borderTop: `4px solid ${isPositive ? '#2e7d32' : isNegative ? '#d32f2f' : '#bdbdbd'}`
|
||||
}}
|
||||
>
|
||||
@@ -66,7 +71,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli
|
||||
<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}
|
||||
{grano.nombre}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
@@ -94,6 +99,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli
|
||||
|
||||
export const GranosCardWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
const [selectedGrano, setSelectedGrano] = useState<string | null>(null);
|
||||
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -105,11 +111,12 @@ export const GranosCardWidget = () => {
|
||||
const handleCloseDialog = () => {
|
||||
setSelectedGrano(null);
|
||||
setTimeout(() => {
|
||||
triggerButtonRef.current?.focus();
|
||||
triggerButtonRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
// El spinner de carga sigue siendo prioritario
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
@@ -123,41 +130,47 @@ export const GranosCardWidget = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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) => (
|
||||
{/* Si es feriado (y la comprobación ha terminado), mostramos la alerta encima */}
|
||||
{isHoliday === true && (
|
||||
<Box sx={{ mb: 2 }}> {/* Añadimos un margen inferior a la alerta */}
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
// Usamos el objeto para definir gaps responsivos
|
||||
gap: {
|
||||
xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical)
|
||||
sm: 4, // 16px en pantallas pequeñas
|
||||
md: 3, // 24px en pantallas medianas (afecta el espaciado horizontal)
|
||||
},
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{data.map((grano) => (
|
||||
<GranoCard key={grano.nombre} grano={grano} onChartClick={(event) => handleChartClick(grano.nombre, event)} />
|
||||
))}
|
||||
</Box>
|
||||
<Dialog open={Boolean(selectedGrano)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleCloseDialog}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -15, // Mueve el botón hacia arriba, fuera del Dialog
|
||||
right: -15, // Mueve el botón hacia la derecha, fuera del Dialog
|
||||
color: (theme) => theme.palette.grey[500],
|
||||
backgroundColor: 'white',
|
||||
boxShadow: 3, // Añade una sombra para que destaque
|
||||
'&:hover': {
|
||||
backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
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} />}
|
||||
|
||||
@@ -2,19 +2,20 @@ import {
|
||||
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
||||
} from '@mui/material';
|
||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
};
|
||||
// Importaciones de nuestro proyecto
|
||||
import type { CotizacionGrano } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatInteger, formatDateOnly, formatFullDateTime } from '../utils/formatters';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Sub-componente para mostrar la variación con icono y color.
|
||||
*/
|
||||
const Variacion = ({ value }: { value: number }) => {
|
||||
const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary';
|
||||
const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon;
|
||||
@@ -23,58 +24,79 @@ const Variacion = ({ value }: { value: number }) => {
|
||||
<Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}>
|
||||
<Icon sx={{ fontSize: '1rem', mr: 0.5 }} />
|
||||
<Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>
|
||||
{formatNumber(value)}
|
||||
{formatInteger(value)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tabla detallada del mercado de granos.
|
||||
*/
|
||||
export const GranosWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||
// Hooks para obtener los datos y el estado de feriado para el mercado argentino.
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
if (loading) {
|
||||
// Estado de carga unificado.
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
// Si no hay ningún dato que mostrar.
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" aria-label="tabla granos">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Grano</TableCell>
|
||||
<TableCell align="right">Precio ($/Tn)</TableCell>
|
||||
<TableCell align="center">Variación</TableCell>
|
||||
<TableCell align="right">Fecha Operación</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row) => (
|
||||
<TableRow key={row.nombre} hover>
|
||||
<TableCell component="th" scope="row">
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">${formatNumber(row.precio)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Variacion value={row.variacionPrecio} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{new Date(row.fechaOperacion).toLocaleDateString('es-AR')}</TableCell>
|
||||
<Box>
|
||||
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" aria-label="tabla granos">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Grano</TableCell>
|
||||
<TableCell align="right">Precio ($/Tn)</TableCell>
|
||||
<TableCell align="center">Variación</TableCell>
|
||||
<TableCell align="right">Fecha Operación</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}>
|
||||
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
||||
Fuente: Bolsa de Comercio de Rosario
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableContainer>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map((row) => (
|
||||
<TableRow key={row.nombre} hover>
|
||||
<TableCell component="th" scope="row">
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">${formatInteger(row.precio)}</TableCell>
|
||||
<TableCell align="center">
|
||||
<Variacion value={row.variacionPrecio} />
|
||||
</TableCell>
|
||||
<TableCell align="right">{formatDateOnly(row.fechaOperacion)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}>
|
||||
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
||||
Fuente: Bolsa de Comercio de Rosario
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { Box, CircularProgress, Alert } from '@mui/material';
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { formatFullDateTime, formatCurrency2Decimal, formatCurrency } from '../utils/formatters';
|
||||
|
||||
interface HistoricalChartWidgetProps {
|
||||
ticker: string;
|
||||
@@ -9,10 +10,13 @@ interface HistoricalChartWidgetProps {
|
||||
dias: number;
|
||||
}
|
||||
|
||||
// Formateador para el eje X (muestra DD/MM)
|
||||
// Formateador para el eje X que solo muestra día/mes
|
||||
const formatXAxis = (tickItem: string) => {
|
||||
const date = new Date(tickItem);
|
||||
return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||
return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
const formatTooltipLabel = (label: string) => {
|
||||
return formatFullDateTime(label);
|
||||
};
|
||||
|
||||
export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => {
|
||||
@@ -24,22 +28,49 @@ export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChart
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error" sx={{height: 300}}>{error}</Alert>;
|
||||
return <Alert severity="error" sx={{ height: 300 }}>{error}</Alert>;
|
||||
}
|
||||
|
||||
if (!data || data.length < 2) {
|
||||
return <Alert severity="info" sx={{height: 300}}>No hay suficientes datos históricos para graficar.</Alert>;
|
||||
return <Alert severity="info" sx={{ height: 300 }}>No hay suficientes datos históricos para graficar.</Alert>;
|
||||
}
|
||||
|
||||
// 1. Calcular el dominio del eje Y con un margen
|
||||
const prices = data.map(p => p.precioActual);
|
||||
const dataMin = Math.min(...prices);
|
||||
const dataMax = Math.max(...prices);
|
||||
const padding = (dataMax - dataMin) * 0.5; // 5% de padding
|
||||
const domainMin = Math.floor(dataMin - padding);
|
||||
const domainMax = Math.ceil(dataMax + padding);
|
||||
|
||||
// 2. Formateador de ticks para el eje Y más robusto
|
||||
const yAxisTickFormatter = (tick: number) => {
|
||||
// Usamos el formateador de moneda
|
||||
return `$${formatCurrency(tick)}`;
|
||||
};
|
||||
|
||||
// 3. Calcular el ancho del eje Y dinámicamente
|
||||
const maxLabel = yAxisTickFormatter(dataMax);
|
||||
// Calculamos un ancho base + un extra por cada carácter en la etiqueta más larga.
|
||||
const dynamicWidth = mercado === 'EEUU' ? 5 + (maxLabel.length * 4.5) : 15 + (maxLabel.length * 5);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} />
|
||||
<YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} />
|
||||
<Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} />
|
||||
<YAxis
|
||||
domain={[domainMin, domainMax]}
|
||||
tickFormatter={yAxisTickFormatter}
|
||||
width={dynamicWidth}
|
||||
tickMargin={5}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${formatCurrency2Decimal(value, mercado === 'EEUU' ? 'USD' : 'ARS')}`, 'Precio']}
|
||||
labelFormatter={formatTooltipLabel}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#028fbe" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="precioActual" name="Precio de Cierre" stroke="#8884d8" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Box, CircularProgress, Alert, Paper, Typography, Dialog,
|
||||
DialogTitle, DialogContent, IconButton
|
||||
@@ -8,131 +8,159 @@ import ScaleIcon from '@mui/icons-material/Scale';
|
||||
import { PiChartLineUpBold } from "react-icons/pi";
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
// Importaciones de nuestro proyecto
|
||||
import type { CotizacionGanado } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { formatCurrency, formatInteger } from '../utils/formatters';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters';
|
||||
import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
// El subcomponente ahora tendrá un botón para el gráfico.
|
||||
const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: () => void }) => {
|
||||
/**
|
||||
* Sub-componente para una única tarjeta de categoría de ganado.
|
||||
*/
|
||||
const AgroCard = ({ registro, onChartClick }: { registro: CotizacionGanado, onChartClick: (event: React.MouseEvent<HTMLButtonElement>) => void }) => {
|
||||
return (
|
||||
// Añadimos posición relativa para poder posicionar el botón del gráfico.
|
||||
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}>
|
||||
<IconButton
|
||||
aria-label="ver historial"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChartClick();
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)', // Fondo semitransparente
|
||||
backdropFilter: 'blur(2px)', // Efecto "frosty glass" para el fondo
|
||||
border: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: '0 2px 5px rgba(0,0,0,0.1)',
|
||||
transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)', // Se eleva un poco
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.2)', // La sombra se hace más grande
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PiChartLineUpBold size="20" />
|
||||
</IconButton>
|
||||
<Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Contenido principal de la tarjeta */}
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<IconButton
|
||||
aria-label="ver historial"
|
||||
onClick={onChartClick}
|
||||
sx={{
|
||||
position: 'absolute', top: 8, right: 8,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
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',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.2)',
|
||||
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 variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 }}>
|
||||
{registro.categoria}
|
||||
<Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Máximo:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<PiCow size="28" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Cabezas</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Máximo:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<ScaleIcon color="action" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Kilos</Typography>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">Precio Mediano:</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>${formatCurrency(registro.mediano)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Pie de la tarjeta */}
|
||||
<Box sx={{ mt: 'auto', pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<PiCow size="28" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.cabezas)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Cabezas</Typography>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<ScaleIcon color="action" />
|
||||
<Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Kilos</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ display: 'block', textAlign: 'left', color: 'text.secondary', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
{formatDateOnly(registro.fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget autónomo para las tarjetas de resumen del Mercado Agroganadero.
|
||||
*/
|
||||
export const MercadoAgroCardWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null);
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
const handleChartClick = (registro: CotizacionGanado) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null);
|
||||
const triggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
const handleChartClick = (registro: CotizacionGanado, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
triggerButtonRef.current = event.currentTarget;
|
||||
setSelectedCategory(registro);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setSelectedCategory(null);
|
||||
setTimeout(() => {
|
||||
triggerButtonRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
if (error) {
|
||||
return <Alert severity="error">{error}</Alert>;
|
||||
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, justifyContent: 'center' }}>
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: { xs: 2, sm: 2, md: 3 }, justifyContent: 'center' }}>
|
||||
{data.map(registro => (
|
||||
<AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} />
|
||||
<AgroCard key={registro.id} registro={registro} onChartClick={(event) => handleChartClick(registro, event)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(selectedCategory)}
|
||||
onClose={handleCloseDialog}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera
|
||||
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
|
||||
position: 'absolute', top: -15, right: -15,
|
||||
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
|
||||
},
|
||||
backgroundColor: 'white', boxShadow: 3,
|
||||
'&:hover': { backgroundColor: 'grey.100' },
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
|
||||
<DialogTitle sx={{ m: 0, p: 2 }}>
|
||||
Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones})
|
||||
Historial de 30 días para {selectedCategory?.categoria} ({selectedCategory?.especificaciones})
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{selectedCategory && (
|
||||
|
||||
@@ -2,13 +2,17 @@ import {
|
||||
Box, CircularProgress, Alert, Table, TableBody, TableCell,
|
||||
TableContainer, TableHead, TableRow, Paper, Typography, Tooltip
|
||||
} from '@mui/material';
|
||||
|
||||
// Importaciones de nuestro proyecto
|
||||
import type { CotizacionGanado } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
// --- 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.
|
||||
/**
|
||||
* Sub-componente para renderizar cada registro como una tarjeta en la vista móvil.
|
||||
*/
|
||||
const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
|
||||
const commonStyles = {
|
||||
cell: {
|
||||
@@ -56,18 +60,42 @@ const AgroDataCard = ({ row }: { row: CotizacionGanado }) => {
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
// --- ^ FIN DE LA MODIFICACIÓN ^ ---
|
||||
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tabla/lista responsiva del Mercado Agroganadero.
|
||||
*/
|
||||
export const MercadoAgroWidget = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
// Hooks para obtener los datos y el estado de feriado.
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; }
|
||||
if (error) { return <Alert severity="error">{error}</Alert>; }
|
||||
if (!data || data.length === 0) { return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; }
|
||||
// Estado de carga unificado.
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box>
|
||||
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* VISTA DE ESCRITORIO (se oculta en móvil) */}
|
||||
<TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}>
|
||||
<Table size="small" aria-label="tabla mercado agroganadero">
|
||||
@@ -107,9 +135,10 @@ export const MercadoAgroWidget = () => {
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Tooltip title={`Última actualización: ${formatFullDateTime(data[0].fechaRegistro)}`}>
|
||||
{/* La información de la fuente se muestra siempre, usando la fecha del primer registro */}
|
||||
<Tooltip title={`Última actualización: ${formatDateOnly(data[0].fechaRegistro)}`}>
|
||||
<Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}>
|
||||
Fuente: Mercado Agroganadero S.A.
|
||||
{formatDateOnly(data[0].fechaRegistro)} - Fuente: Mercado Agroganadero S.A.
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
@@ -5,13 +5,17 @@ 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 { formatCurrency2Decimal, formatCurrency } from '../utils/formatters';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday'; // <-- Importamos el hook
|
||||
import { HolidayAlert } from './common/HolidayAlert'; // <-- Importamos la alerta
|
||||
|
||||
// --- V SUB-COMPONENTE AÑADIDO V ---
|
||||
/**
|
||||
* Sub-componente para la variación del índice.
|
||||
*/
|
||||
const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
||||
if (anterior === 0) return null; // Evitar división por cero
|
||||
if (anterior === 0) return null;
|
||||
const variacionPuntos = actual - anterior;
|
||||
const variacionPorcentaje = (variacionPuntos / anterior) * 100;
|
||||
|
||||
@@ -34,44 +38,79 @@ const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: numbe
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
// --- ^ SUB-COMPONENTE AÑADIDO ^ ---
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tarjeta de héroe del S&P Merval.
|
||||
*/
|
||||
export const MervalHeroCard = () => {
|
||||
const { data: allLocalData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||
// Cada widget gestiona sus propias llamadas a la API
|
||||
const { data: allLocalData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
// Estado interno para el gráfico
|
||||
const [dias, setDias] = useState<number>(30);
|
||||
|
||||
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
||||
if (nuevoRango !== null) { setDias(nuevoRango); }
|
||||
};
|
||||
|
||||
// Filtramos el dato específico que este widget necesita
|
||||
const mervalData = allLocalData?.find(d => d.ticker === '^MERV');
|
||||
|
||||
// --- LÓGICA DE RENDERIZADO CORREGIDA ---
|
||||
|
||||
// El estado de carga depende de AMBAS llamadas a la API.
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
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>; }
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: '288px' }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
// Si no hay datos del Merval, es un estado final.
|
||||
if (!mervalData) {
|
||||
// Si no hay datos PERO sabemos que es feriado, la alerta de feriado es más informativa.
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>;
|
||||
}
|
||||
|
||||
// Si llegamos aquí, SÍ tenemos datos para mostrar.
|
||||
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>
|
||||
{/* Si es feriado, mostramos la alerta como un AVISO encima del contenido. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
{/* Ahora sí encontrará el componente */}
|
||||
<VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} />
|
||||
)}
|
||||
|
||||
{/* El contenido principal del widget siempre se muestra si hay datos. */}
|
||||
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography>
|
||||
<Typography variant="h4" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatCurrency2Decimal(mervalData.precioActual)}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<VariacionMerval actual={mervalData.precioActual} anterior={mervalData.cierreAnterior} />
|
||||
</Box>
|
||||
</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 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>
|
||||
<HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import React, { 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';
|
||||
|
||||
// Importaciones de nuestro proyecto
|
||||
import type { CotizacionBolsa } from '../models/mercadoModels';
|
||||
import { useApiData } from '../hooks/useApiData';
|
||||
import { useIsHoliday } from '../hooks/useIsHoliday';
|
||||
import { formatInteger } from '../utils/formatters';
|
||||
import { HistoricalChartWidget } from './HistoricalChartWidget';
|
||||
import { HolidayAlert } from './common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Sub-componente interno para mostrar la variación del índice.
|
||||
*/
|
||||
const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => {
|
||||
if (anterior === 0) return null;
|
||||
const variacionPuntos = actual - anterior;
|
||||
@@ -33,41 +40,74 @@ const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget autónomo para la tarjeta de héroe del S&P 500.
|
||||
*/
|
||||
export const UsaIndexHeroCard = () => {
|
||||
const { data: allUsaData, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
// Hooks para obtener los datos y el estado de feriado para el mercado de EEUU.
|
||||
const { data: allUsaData, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
const isHoliday = useIsHoliday('US');
|
||||
|
||||
// Estado interno para el gráfico.
|
||||
const [dias, setDias] = useState<number>(30);
|
||||
|
||||
const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => {
|
||||
if (nuevoRango !== null) { setDias(nuevoRango); }
|
||||
};
|
||||
|
||||
// Filtramos el dato específico que este widget necesita.
|
||||
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>; }
|
||||
// Estado de carga unificado: esperamos a que AMBAS llamadas a la API terminen.
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', p: 4, height: { xs: 'auto', md: '445px' } }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (dataError) {
|
||||
return <Alert severity="error">{dataError}</Alert>;
|
||||
}
|
||||
|
||||
// Si no hay datos del S&P 500, mostramos el mensaje apropiado.
|
||||
if (!indexData) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>;
|
||||
}
|
||||
|
||||
// Si hay datos, renderizamos el contenido completo.
|
||||
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>
|
||||
{/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */}
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
<Box sx={{ pt: 2 }}>
|
||||
<VariacionIndex actual={indexData.precioActual} anterior={indexData.cierreAnterior} />
|
||||
)}
|
||||
|
||||
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography>
|
||||
<Typography variant="h4" 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>
|
||||
<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 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>
|
||||
<HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} />
|
||||
</Box>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
16
frontend/src/components/common/HolidayAlert.tsx
Normal file
16
frontend/src/components/common/HolidayAlert.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Alert } from '@mui/material';
|
||||
import CelebrationIcon from '@mui/icons-material/Celebration';
|
||||
import { formatDateOnly } from '../../utils/formatters';
|
||||
|
||||
export const HolidayAlert = () => {
|
||||
// Obtener la fecha actual en la zona horaria de Buenos Aires
|
||||
const nowInBuenosAires = new Date(
|
||||
new Date().toLocaleString('en-US', { timeZone: 'America/Argentina/Buenos_Aires' })
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert severity="info" icon={<CelebrationIcon fontSize="inherit" />}>
|
||||
{formatDateOnly(nowInBuenosAires.toISOString())} Mercado cerrado por feriado.
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -1,80 +1,96 @@
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } 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');
|
||||
// Importaciones de nuestro proyecto
|
||||
import { useApiData } from '../../hooks/useApiData';
|
||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
||||
import type { CotizacionGanado } from '../../models/mercadoModels';
|
||||
import { formatInteger, formatFullDateTime } from '../../utils/formatters';
|
||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||
import { HolidayAlert } from '../common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Función para convertir los datos a formato TSV (Tab-Separated Values)
|
||||
* con el formato específico solicitado por redacción.
|
||||
*/
|
||||
const toTSV = (data: CotizacionGanado[]) => {
|
||||
const dataRows = data.map(row => {
|
||||
// Unimos Categoría y Especificaciones en una sola columna para el copiado
|
||||
const categoriaCompleta = `${row.categoria}/${row.especificaciones}`;
|
||||
const cabezas = formatInteger(row.cabezas);
|
||||
const importeTotal = formatInteger(row.importeTotal);
|
||||
|
||||
return [categoriaCompleta, cabezas, importeTotal].join('\t');
|
||||
});
|
||||
|
||||
return dataRows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente de tabla de datos crudos para el Mercado Agroganadero,
|
||||
* diseñado para la página de redacción.
|
||||
*/
|
||||
export const RawAgroTable = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
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);
|
||||
const tsvData = toTSV(data);
|
||||
|
||||
copyToClipboard(csvData)
|
||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
||||
copyToClipboard(tsvData)
|
||||
.then(() => alert('Datos del Mercado Agroganadero copiados 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;
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||
Copiar como CSV
|
||||
</Button>
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||
Copiar Datos para Redacción
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* La tabla ahora muestra solo las columnas requeridas para facilitar la visualización */}
|
||||
<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>Categoría / Especificaciones</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>{row.categoria} / {row.especificaciones}</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>
|
||||
|
||||
@@ -1,37 +1,47 @@
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } 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';
|
||||
import { TICKERS_PRIORITARIOS_LOCAL } from '../../config/priorityTickers';
|
||||
|
||||
// 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');
|
||||
/**
|
||||
* Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
|
||||
*/
|
||||
const toTSV = (data: CotizacionBolsa[]) => {
|
||||
const dataRows = data.map(row => {
|
||||
// Formateamos el nombre para que quede como "GGAL.BA (GRUPO FINANCIERO GALICIA)"
|
||||
const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
|
||||
const precio = `$${formatCurrency(row.precioActual)}`
|
||||
const cambio = `${formatCurrency(row.porcentajeCambio)}%` //`${row.porcentajeCambio.toFixed(2)}%`
|
||||
|
||||
// Unimos los campos con un carácter de tabulación '\t'
|
||||
return [nombreCompleto, precio, cambio].join('\t');
|
||||
});
|
||||
|
||||
// Unimos todas las filas con un salto de línea
|
||||
return dataRows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente de tabla de datos crudos para la Bolsa Local, adaptado para redacción.
|
||||
*/
|
||||
export const RawBolsaLocalTable = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local');
|
||||
|
||||
// Separamos los datos en prioritarios y el resto
|
||||
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker))
|
||||
.sort((a, b) => TICKERS_PRIORITARIOS_LOCAL.indexOf(a.ticker) - TICKERS_PRIORITARIOS_LOCAL.indexOf(b.ticker)); // Mantenemos el orden
|
||||
|
||||
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker));
|
||||
|
||||
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!');
|
||||
})
|
||||
if (!priorityData) return;
|
||||
const tsvData = toTSV(priorityData);
|
||||
|
||||
copyToClipboard(tsvData)
|
||||
.then(() => alert('Datos prioritarios copiados al portapapeles.'))
|
||||
.catch(err => {
|
||||
console.error('Error al copiar:', err);
|
||||
alert('Error: No se pudo copiar la tabla.');
|
||||
@@ -40,39 +50,75 @@ export const RawBolsaLocalTable = () => {
|
||||
|
||||
if (loading) return <CircularProgress />;
|
||||
if (error) return <Alert severity="error">{error}</Alert>;
|
||||
if (!data) return null;
|
||||
if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||
Copiar como CSV
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||
Copiar Datos Principales
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tabla de Datos Prioritarios */}
|
||||
<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>
|
||||
<TableCell>Símbolo (Nombre)</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right">% Cambio</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map(row => (
|
||||
{priorityData?.map(row => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.ticker}</TableCell>
|
||||
<TableCell>{row.nombreEmpresa}</TableCell>
|
||||
<TableCell>
|
||||
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||
</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>
|
||||
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Sección para Otros Tickers (solo para consulta) */}
|
||||
{otherData && otherData.length > 0 && (
|
||||
<Box mt={4}>
|
||||
<Divider sx={{ mb: 2 }}>
|
||||
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
|
||||
</Divider>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo (Nombre)</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right">% Cambio</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{otherData.map(row => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +1,135 @@
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
|
||||
// Importaciones de nuestro proyecto
|
||||
import { useApiData } from '../../hooks/useApiData';
|
||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
||||
import type { CotizacionBolsa } from '../../models/mercadoModels';
|
||||
import { formatCurrency, formatFullDateTime } from '../../utils/formatters';
|
||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||
import { HolidayAlert } from '../common/HolidayAlert';
|
||||
import { TICKERS_PRIORITARIOS_USA } from '../../config/priorityTickers';
|
||||
|
||||
// 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');
|
||||
/**
|
||||
* Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values).
|
||||
*/
|
||||
const toTSV = (data: CotizacionBolsa[]) => {
|
||||
const dataRows = data.map(row => {
|
||||
// Formateamos los datos según los requisitos de redacción
|
||||
const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`;
|
||||
const precio = `$${formatCurrency(row.precioActual)}`;
|
||||
const cambio = `${formatCurrency(row.porcentajeCambio)}%`;
|
||||
|
||||
return [nombreCompleto, precio, cambio].join('\t');
|
||||
});
|
||||
return dataRows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs,
|
||||
* adaptado para las necesidades de redacción.
|
||||
*/
|
||||
export const RawBolsaUsaTable = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu');
|
||||
const isHoliday = useIsHoliday('US');
|
||||
|
||||
// Separamos los datos en prioritarios y el resto, manteniendo el orden de la lista
|
||||
const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_USA.includes(d.ticker))
|
||||
.sort((a, b) => TICKERS_PRIORITARIOS_USA.indexOf(a.ticker) - TICKERS_PRIORITARIOS_USA.indexOf(b.ticker));
|
||||
|
||||
const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_USA.includes(d.ticker));
|
||||
|
||||
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!'))
|
||||
if (!priorityData) return;
|
||||
const tsvData = toTSV(priorityData);
|
||||
|
||||
copyToClipboard(tsvData)
|
||||
.then(() => alert('Datos prioritarios copiados 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>;
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) { return <HolidayAlert />; }
|
||||
return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||
Copiar como CSV
|
||||
</Button>
|
||||
{isHoliday && <Box sx={{ mb: 2 }}><HolidayAlert /></Box>}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||
Copiar Datos Principales
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tabla de Datos Prioritarios */}
|
||||
<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>
|
||||
<TableCell>Símbolo (Nombre)</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right">% Cambio</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.map(row => (
|
||||
{priorityData?.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>
|
||||
<TableCell>
|
||||
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Sección para Otros Tickers (solo para consulta) */}
|
||||
{otherData && otherData.length > 0 && (
|
||||
<Box mt={4}>
|
||||
<Divider sx={{ mb: 2 }}>
|
||||
<Typography variant="overline">Otros Tickers (Solo Consulta)</Typography>
|
||||
</Divider>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Símbolo (Nombre)</TableCell>
|
||||
<TableCell align="right">Precio Actual</TableCell>
|
||||
<TableCell align="right">% Cambio</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{otherData.map(row => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography>
|
||||
<Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">${formatCurrency(row.precioActual)}</TableCell>
|
||||
<TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,83 @@
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material';
|
||||
import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } 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');
|
||||
// Importaciones de nuestro proyecto
|
||||
import { useApiData } from '../../hooks/useApiData';
|
||||
import { useIsHoliday } from '../../hooks/useIsHoliday';
|
||||
import type { CotizacionGrano } from '../../models/mercadoModels';
|
||||
import { formatInteger, formatFullDateTime } from '../../utils/formatters';
|
||||
import { copyToClipboard } from '../../utils/clipboardUtils';
|
||||
import { HolidayAlert } from '../common/HolidayAlert';
|
||||
|
||||
/**
|
||||
* Función para convertir los datos a formato TSV (Tab-Separated Values)
|
||||
* con el formato específico solicitado por redacción.
|
||||
*/
|
||||
const toTSV = (data: CotizacionGrano[]) => {
|
||||
const dataRows = data.map(row => {
|
||||
// Formateamos la variación para que muestre "=" si es cero.
|
||||
const variacion = row.variacionPrecio === 0
|
||||
? '= 0'
|
||||
: formatInteger(row.variacionPrecio);
|
||||
|
||||
const precio = formatInteger(row.precio);
|
||||
|
||||
// Unimos los campos con un carácter de tabulación '\t'
|
||||
return [row.nombre, precio, variacion].join('\t');
|
||||
});
|
||||
|
||||
return dataRows.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Componente de tabla de datos crudos para el mercado de Granos,
|
||||
* diseñado para la página de redacción.
|
||||
*/
|
||||
export const RawGranosTable = () => {
|
||||
const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||
const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos');
|
||||
const isHoliday = useIsHoliday('BA');
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!data) return;
|
||||
const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."];
|
||||
const csvData = toCSV(headers, data);
|
||||
const tsvData = toTSV(data);
|
||||
|
||||
copyToClipboard(csvData)
|
||||
.then(() => alert('¡Tabla copiada al portapapeles!'))
|
||||
copyToClipboard(tsvData)
|
||||
.then(() => alert('Datos de Granos copiados 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;
|
||||
const isLoading = dataLoading || isHoliday === null;
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (dataError) return <Alert severity="error">{dataError}</Alert>;
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (isHoliday) {
|
||||
return <HolidayAlert />;
|
||||
}
|
||||
return <Alert severity="info">No hay datos de granos disponibles.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}>
|
||||
Copiar como CSV
|
||||
</Button>
|
||||
{isHoliday && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<HolidayAlert />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Button startIcon={<ContentCopyIcon />} onClick={handleCopy}>
|
||||
Copiar Datos para Redacción
|
||||
</Button>
|
||||
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
|
||||
Última actualización: {formatFullDateTime(data[0].fechaRegistro)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
@@ -51,8 +85,6 @@ export const RawGranosTable = () => {
|
||||
<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>
|
||||
@@ -60,9 +92,7 @@ export const RawGranosTable = () => {
|
||||
<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>
|
||||
<TableCell align="right">{row.variacionPrecio === 0 ? '= 0' : formatInteger(row.variacionPrecio)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
11
frontend/src/config/priorityTickers.ts
Normal file
11
frontend/src/config/priorityTickers.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const TICKERS_PRIORITARIOS_LOCAL = [
|
||||
'^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'
|
||||
];
|
||||
|
||||
// Dejaremos las otras listas aquí para los siguientes componentes
|
||||
export const TICKERS_PRIORITARIOS_USA = [
|
||||
'AAPL', 'AMD', 'AMZN', 'BRK-B', 'KO', 'MSFT', 'NVDA',
|
||||
'GLD', 'XLF', 'XLI', 'XLE', 'XLK', 'MELI'
|
||||
];
|
||||
@@ -2,8 +2,10 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import apiClient from '../api/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
// T es el tipo de dato que esperamos de la API (ej. CotizacionBolsa[])
|
||||
export function useApiData<T>(url: string) {
|
||||
// Definimos la URL de la API en un solo lugar y de forma explícita.
|
||||
const API_ROOT = 'https://widgets.eldia.com/api';
|
||||
|
||||
export function useApiData<T>(endpoint: string) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -12,11 +14,13 @@ export function useApiData<T>(url: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get<T>(url);
|
||||
// Construimos la URL completa y absoluta para la llamada.
|
||||
const fullUrl = `${API_ROOT}${endpoint}`;
|
||||
const response = await apiClient.get<T>(fullUrl);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError) {
|
||||
setError(`Error al cargar datos: ${err.message}`);
|
||||
setError(`Error de red o de la API: ${err.message}`);
|
||||
} else {
|
||||
setError('Ocurrió un error inesperado.');
|
||||
}
|
||||
@@ -24,7 +28,7 @@ export function useApiData<T>(url: string) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [url]);
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
||||
21
frontend/src/hooks/useIsHoliday.ts
Normal file
21
frontend/src/hooks/useIsHoliday.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useApiData } from './useApiData';
|
||||
|
||||
export function useIsHoliday(marketCode: 'BA' | 'US') {
|
||||
// Reutilizamos el hook que ya sabe cómo obtener datos de nuestra API.
|
||||
// Le pasamos el endpoint específico para los feriados.
|
||||
const { data: isHoliday, loading, error } = useApiData<boolean>(`/mercados/es-feriado/${marketCode}`);
|
||||
|
||||
// Si hay un error al cargar los feriados, por seguridad asumimos que no es feriado.
|
||||
if (error) {
|
||||
console.error(`Error al verificar feriado para ${marketCode}, asumiendo que no lo es.`, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Si está cargando, devolvemos null para que el componente sepa que debe esperar.
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Devolvemos el dato booleano que llegó de la API.
|
||||
return isHoliday;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ const widgetRegistry = {
|
||||
'mercado-agro-tarjetas': MercadoAgroCardWidget,
|
||||
'mercado-agro-tabla': MercadoAgroWidget,
|
||||
|
||||
// Página completa como un widget
|
||||
// Widget Página datos crudos
|
||||
'pagina-datos-crudos': RawDataView,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ export const formatCurrency = (num: number, currency = 'ARS') => {
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
export const formatCurrency2Decimal = (num: number, currency = 'ARS') => {
|
||||
return new Intl.NumberFormat('es-AR', {
|
||||
style: 'decimal',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
export const formatInteger = (num: number) => {
|
||||
return new Intl.NumberFormat('es-AR').format(num);
|
||||
};
|
||||
@@ -23,7 +32,7 @@ export const formatFullDateTime = (dateString: string) => {
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'America/Argentina/Buenos_Aires',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,33 +1,24 @@
|
||||
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/
|
||||
// frontend/vite.config.ts
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
// --- 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'
|
||||
}
|
||||
}
|
||||
outDir: 'dist',
|
||||
manifest: 'manifest.json', // Esto asegura que se llame 'manifest.json' y esté en la raíz de 'dist'
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// Cualquier petición que empiece con /api...
|
||||
'/api': {
|
||||
// ...redirígela a nuestro backend de .NET
|
||||
target: 'http://localhost:5045',
|
||||
// Cambia el origen de la petición para que el backend la acepte
|
||||
changeOrigin: true,
|
||||
// No necesitamos reescribir la ruta, ya que el backend espera /api/...
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
using Mercados.Core.Entities;
|
||||
using Mercados.Infrastructure.Persistence.Repositories;
|
||||
using Mercados.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Mercados.Api.Controllers
|
||||
@@ -11,6 +12,7 @@ namespace Mercados.Api.Controllers
|
||||
private readonly ICotizacionBolsaRepository _bolsaRepo;
|
||||
private readonly ICotizacionGranoRepository _granoRepo;
|
||||
private readonly ICotizacionGanadoRepository _ganadoRepo;
|
||||
private readonly IHolidayService _holidayService;
|
||||
private readonly ILogger<MercadosController> _logger;
|
||||
|
||||
// Inyectamos TODOS los repositorios que necesita el controlador.
|
||||
@@ -18,11 +20,13 @@ namespace Mercados.Api.Controllers
|
||||
ICotizacionBolsaRepository bolsaRepo,
|
||||
ICotizacionGranoRepository granoRepo,
|
||||
ICotizacionGanadoRepository ganadoRepo,
|
||||
IHolidayService holidayService,
|
||||
ILogger<MercadosController> logger)
|
||||
{
|
||||
_bolsaRepo = bolsaRepo;
|
||||
_granoRepo = granoRepo;
|
||||
_ganadoRepo = ganadoRepo;
|
||||
_holidayService = holidayService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -34,7 +38,7 @@ namespace Mercados.Api.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = await _ganadoRepo.ObtenerUltimaTandaAsync();
|
||||
var data = await _ganadoRepo.ObtenerUltimaTandaDisponibleAsync();
|
||||
return Ok(data);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -147,5 +151,30 @@ namespace Mercados.Api.Controllers
|
||||
return StatusCode(500, "Ocurrió un error interno en el servidor.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("es-feriado/{mercado}")]
|
||||
[ProducesResponseType(typeof(bool), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> IsMarketHoliday(string mercado)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Usamos la fecha actual en la zona horaria de Argentina
|
||||
TimeZoneInfo argentinaTimeZone;
|
||||
try { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); }
|
||||
catch { argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); }
|
||||
|
||||
var todayInArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, argentinaTimeZone);
|
||||
|
||||
var esFeriado = await _holidayService.IsMarketHolidayAsync(mercado.ToUpper(), todayInArgentina);
|
||||
return Ok(esFeriado);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error al comprobar si es feriado para el mercado {Mercado}.", mercado);
|
||||
// Si hay un error, devolvemos 'false' para no bloquear la UI innecesariamente.
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/Mercados.Api/Dockerfile
Normal file
27
src/Mercados.Api/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# --- Etapa 1: Build ---
|
||||
# Usamos la imagen del SDK de .NET 8 para compilar la aplicación
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copiamos los archivos .csproj de cada proyecto para restaurar las dependencias de forma eficiente
|
||||
COPY ["src/Mercados.Api/Mercados.Api.csproj", "Mercados.Api/"]
|
||||
COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"]
|
||||
COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"]
|
||||
COPY ["src/Mercados.Database/Mercados.Database.csproj", "Mercados.Database/"]
|
||||
RUN dotnet restore "Mercados.Api/Mercados.Api.csproj"
|
||||
|
||||
# Copiamos el resto del código fuente
|
||||
COPY src/. .
|
||||
|
||||
# Publicamos la aplicación en modo Release, optimizada para producción
|
||||
WORKDIR "/src/Mercados.Api"
|
||||
RUN dotnet publish "Mercados.Api.csproj" -c Release -o /app/publish
|
||||
|
||||
# --- Etapa 2: Final ---
|
||||
# Usamos la imagen de runtime de ASP.NET, que es mucho más ligera que la del SDK
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Definimos el punto de entrada para ejecutar la aplicación cuando el contenedor arranque
|
||||
ENTRYPOINT ["dotnet", "Mercados.Api.dll"]
|
||||
@@ -4,6 +4,8 @@ using Mercados.Infrastructure;
|
||||
using Mercados.Infrastructure.Persistence;
|
||||
using Mercados.Infrastructure.Persistence.Repositories;
|
||||
using Mercados.Api.Utils;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Mercados.Infrastructure.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -18,7 +20,8 @@ builder.Services.AddCors(options =>
|
||||
{
|
||||
policy.WithOrigins("http://localhost:5173",
|
||||
"http://192.168.10.78:5173",
|
||||
"https://www.eldia.com")
|
||||
"https://www.eldia.com",
|
||||
"https://extras.eldia.com")
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod();
|
||||
});
|
||||
@@ -30,6 +33,10 @@ builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoReposito
|
||||
builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>();
|
||||
builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>();
|
||||
builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>();
|
||||
builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||
builder.Services.AddScoped<IHolidayService, FinnhubHolidayService>();
|
||||
|
||||
// Configuración de FluentMigrator (perfecto)
|
||||
builder.Services
|
||||
@@ -48,12 +55,23 @@ builder.Services.AddControllers()
|
||||
options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
||||
});
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders =
|
||||
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
// En un entorno de producción real, deberías limitar esto a las IPs de tus proxies.
|
||||
// options.KnownProxies.Add(IPAddress.Parse("192.168.5.X")); // IP de tu NPM
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Le decimos a la aplicación que USE el middleware de cabeceras de reenvío.
|
||||
// ¡El orden importa! Debe ir antes de UseHttpsRedirection y UseCors.
|
||||
app.UseForwardedHeaders();
|
||||
|
||||
// Ejecución de migraciones (perfecto)
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
|
||||
10
src/Mercados.Core/Entities/MercadoFeriado.cs
Normal file
10
src/Mercados.Core/Entities/MercadoFeriado.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Mercados.Core.Entities
|
||||
{
|
||||
public class MercadoFeriado
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string CodigoMercado { get; set; } = string.Empty; // "US" o "BA"
|
||||
public DateTime Fecha { get; set; }
|
||||
public string? Nombre { get; set; } // Nombre del feriado, si la API lo provee
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ using FluentMigrator;
|
||||
|
||||
namespace Mercados.Database.Migrations
|
||||
{
|
||||
[Migration(20240702133000)]
|
||||
[Migration(20250702133000)]
|
||||
public class AddNameToStocks : Migration
|
||||
{
|
||||
public override void Up()
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentMigrator;
|
||||
|
||||
namespace Mercados.Database.Migrations
|
||||
{
|
||||
[Migration(20250714150000)]
|
||||
public class CreateMercadoFeriadoTable : Migration
|
||||
{
|
||||
private const string TableName = "MercadosFeriados";
|
||||
|
||||
public override void Up()
|
||||
{
|
||||
Create.Table(TableName)
|
||||
.WithColumn("Id").AsInt64().PrimaryKey().Identity()
|
||||
.WithColumn("CodigoMercado").AsString(10).NotNullable()
|
||||
.WithColumn("Fecha").AsDate().NotNullable() // Usamos AsDate() para guardar solo la fecha
|
||||
.WithColumn("Nombre").AsString(255).Nullable();
|
||||
|
||||
// Creamos un índice para buscar rápidamente por mercado y fecha
|
||||
Create.Index($"IX_{TableName}_CodigoMercado_Fecha")
|
||||
.OnTable(TableName)
|
||||
.OnColumn("CodigoMercado").Ascending()
|
||||
.OnColumn("Fecha").Ascending()
|
||||
.WithOptions().Unique();
|
||||
}
|
||||
|
||||
public override void Down()
|
||||
{
|
||||
Delete.Table(TableName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,19 @@ namespace Mercados.Infrastructure.DataFetchers
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formatea el nombre del grano para corregir acentos u otros detalles.
|
||||
/// </summary>
|
||||
private string FormatearNombreGrano(string nombreOriginal)
|
||||
{
|
||||
if (nombreOriginal.Equals("Maiz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Devuelve la versión con el caso de la primera letra original, pero con acento.
|
||||
return char.IsUpper(nombreOriginal[0]) ? "Maíz" : "maíz";
|
||||
}
|
||||
return nombreOriginal; // Devuelve el original si no hay ninguna regla
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||
{
|
||||
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
||||
@@ -96,7 +109,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
||||
{
|
||||
cotizaciones.Add(new CotizacionGrano
|
||||
{
|
||||
Nombre = grain.Key,
|
||||
Nombre = FormatearNombreGrano(grain.Key),
|
||||
Precio = latestRecord.PrecioCotizacion,
|
||||
VariacionPrecio = latestRecord.VariacionPrecioCotizacion,
|
||||
FechaOperacion = latestRecord.FechaOperacionPizarra,
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
||||
// Empresas 'Latinas' en Wall Street
|
||||
"MELI", "GLOB",
|
||||
// ADRs Argentinos
|
||||
"YPF", "GGAL", "BMA", "LOMA", "PAM", "TEO", "TGS", "EDN", "CRESY", "CEPU", "BBAR"
|
||||
"YPF", "GGAL", "BMA", "LOMA", "TEO", "TGS", "EDN", "BBAR"
|
||||
};
|
||||
|
||||
private readonly FinnhubClient _client;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Json;
|
||||
using Mercados.Core.Entities;
|
||||
using Mercados.Infrastructure.Persistence.Repositories;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Mercados.Infrastructure.DataFetchers
|
||||
{
|
||||
// Añadimos las clases DTO necesarias para deserializar la respuesta de Finnhub
|
||||
public class MarketHolidayResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public List<MarketHoliday>? Data { get; set; }
|
||||
}
|
||||
public class MarketHoliday
|
||||
{
|
||||
[JsonPropertyName("at")]
|
||||
public string? At { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!));
|
||||
}
|
||||
|
||||
public class HolidayDataFetcher : IDataFetcher
|
||||
{
|
||||
public string SourceName => "Holidays";
|
||||
private readonly string[] _marketCodes = { "US", "BA" };
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMercadoFeriadoRepository _feriadoRepository;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<HolidayDataFetcher> _logger;
|
||||
|
||||
public HolidayDataFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMercadoFeriadoRepository feriadoRepository,
|
||||
IConfiguration configuration,
|
||||
ILogger<HolidayDataFetcher> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_feriadoRepository = feriadoRepository;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||
{
|
||||
_logger.LogInformation("Iniciando actualización de feriados.");
|
||||
var apiKey = _configuration["ApiKeys:Finnhub"];
|
||||
if (string.IsNullOrEmpty(apiKey)) return (false, "API Key de Finnhub no configurada.");
|
||||
|
||||
var client = _httpClientFactory.CreateClient("FinnhubDataFetcher");
|
||||
|
||||
foreach (var marketCode in _marketCodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}";
|
||||
// Ahora la deserialización funcionará porque la clase existe
|
||||
var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl);
|
||||
|
||||
if (response?.Data != null)
|
||||
{
|
||||
var nuevosFeriados = response.Data.Select(h => new MercadoFeriado
|
||||
{
|
||||
CodigoMercado = marketCode,
|
||||
Fecha = h.Date.ToDateTime(TimeOnly.MinValue),
|
||||
Nombre = "Feriado Bursátil"
|
||||
}).ToList();
|
||||
|
||||
await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados);
|
||||
_logger.LogInformation("Feriados para {MarketCode} actualizados exitosamente: {Count} registros.", marketCode, nuevosFeriados.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode);
|
||||
}
|
||||
}
|
||||
return (true, "Actualización de feriados completada.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ namespace Mercados.Infrastructure.DataFetchers
|
||||
private readonly ICotizacionGanadoRepository _cotizacionRepository;
|
||||
private readonly IFuenteDatoRepository _fuenteDatoRepository;
|
||||
private readonly ILogger<MercadoAgroFetcher> _logger;
|
||||
private readonly TimeZoneInfo _argentinaTimeZone;
|
||||
|
||||
public MercadoAgroFetcher(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
@@ -25,40 +26,80 @@ namespace Mercados.Infrastructure.DataFetchers
|
||||
_cotizacionRepository = cotizacionRepository;
|
||||
_fuenteDatoRepository = fuenteDatoRepository;
|
||||
_logger = logger;
|
||||
try
|
||||
{
|
||||
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires");
|
||||
}
|
||||
catch (TimeZoneNotFoundException)
|
||||
{
|
||||
_argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(bool Success, string Message)> FetchDataAsync()
|
||||
{
|
||||
_logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName);
|
||||
|
||||
try
|
||||
{
|
||||
var htmlContent = await GetHtmlContentAsync();
|
||||
if (string.IsNullOrEmpty(htmlContent))
|
||||
if (string.IsNullOrEmpty(htmlContent)) return (false, "No se pudo obtener el contenido HTML.");
|
||||
|
||||
var cotizacionesNuevas = ParseHtmlToEntities(htmlContent);
|
||||
if (!cotizacionesNuevas.Any())
|
||||
{
|
||||
// Esto sigue siendo un fallo, no se pudo obtener la página
|
||||
return (false, "No se pudo obtener el contenido HTML.");
|
||||
return (true, "Conexión exitosa, pero no se encontraron nuevos datos de ganado.");
|
||||
}
|
||||
|
||||
var cotizaciones = ParseHtmlToEntities(htmlContent);
|
||||
var ahoraEnArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone);
|
||||
var hoy = DateOnly.FromDateTime(ahoraEnArgentina);
|
||||
var cotizacionesViejas = await _cotizacionRepository.ObtenerTandaPorFechaAsync(hoy);
|
||||
|
||||
if (!cotizaciones.Any())
|
||||
// Si el número de registros es diferente, sabemos que hay cambios.
|
||||
if (cotizacionesViejas.Count() != cotizacionesNuevas.Count)
|
||||
{
|
||||
// La conexión fue exitosa, pero no se encontraron datos válidos.
|
||||
// Esto NO es un error crítico, es un estado informativo.
|
||||
_logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", SourceName);
|
||||
return (true, "Conexión exitosa, pero no se encontraron nuevos datos.");
|
||||
_logger.LogInformation("El número de registros de {SourceName} ha cambiado. Actualizando...", SourceName);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Si el número de registros es el mismo, comparamos el contenido.
|
||||
// Convertimos los nuevos a un diccionario para una búsqueda rápida por clave.
|
||||
var nuevasDict = cotizacionesNuevas.ToDictionary(c => $"{c.Categoria}|{c.Especificaciones}");
|
||||
|
||||
bool hayCambios = false;
|
||||
foreach (var cotizacionVieja in cotizacionesViejas)
|
||||
{
|
||||
var clave = $"{cotizacionVieja.Categoria}|{cotizacionVieja.Especificaciones}";
|
||||
|
||||
// Buscamos si el registro viejo existe en los nuevos y comparamos los valores.
|
||||
if (!nuevasDict.TryGetValue(clave, out var cotizacionNueva) ||
|
||||
cotizacionVieja.Maximo != cotizacionNueva.Maximo ||
|
||||
cotizacionVieja.Minimo != cotizacionNueva.Minimo ||
|
||||
cotizacionVieja.Cabezas != cotizacionNueva.Cabezas ||
|
||||
cotizacionVieja.KilosTotales != cotizacionNueva.KilosTotales)
|
||||
{
|
||||
hayCambios = true;
|
||||
_logger.LogInformation("Se detectó un cambio en la categoría: {Categoria}", clave);
|
||||
break; // Encontramos un cambio, no necesitamos seguir buscando.
|
||||
}
|
||||
}
|
||||
|
||||
if (!hayCambios)
|
||||
{
|
||||
_logger.LogInformation("No se encontraron cambios en los datos de {SourceName}. No se requiere actualización.", SourceName);
|
||||
return (true, "Datos sin cambios.");
|
||||
}
|
||||
}
|
||||
|
||||
await _cotizacionRepository.GuardarMuchosAsync(cotizaciones);
|
||||
_logger.LogInformation("Se detectaron cambios en los datos de {SourceName}. Reemplazando la tanda del día...", SourceName);
|
||||
await _cotizacionRepository.ReemplazarTandaDelDiaAsync(hoy, cotizacionesNuevas);
|
||||
|
||||
await UpdateSourceInfoAsync();
|
||||
|
||||
_logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count);
|
||||
return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros.");
|
||||
_logger.LogInformation("Fetch para {SourceName} completado. Se actualizaron {Count} registros.", SourceName, cotizacionesNuevas.Count);
|
||||
return (true, $"Proceso completado. Se actualizaron {cotizacionesNuevas.Count} registros.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Un catch aquí sí es un error real (ej. 404, timeout, etc.)
|
||||
_logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName);
|
||||
return (false, $"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="MailKit" Version="4.13.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="9.0.6" />
|
||||
|
||||
@@ -13,6 +13,59 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync()
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
// Esta consulta busca la fecha más reciente que tiene registros
|
||||
// y luego devuelve todos los registros de esa fecha. Es la lógica original y correcta.
|
||||
const string sql = @"
|
||||
SELECT * FROM CotizacionesGanado
|
||||
WHERE CONVERT(date, FechaRegistro) = (
|
||||
SELECT TOP 1 CONVERT(date, FechaRegistro)
|
||||
FROM CotizacionesGanado
|
||||
ORDER BY FechaRegistro DESC
|
||||
);";
|
||||
return await connection.QueryAsync<CotizacionGanado>(sql);
|
||||
}
|
||||
|
||||
// Este método lo sigue necesitando el Fetcher para comparar los datos del día.
|
||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha)
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
const string sql = @"
|
||||
SELECT * FROM CotizacionesGanado
|
||||
WHERE CONVERT(date, FechaRegistro) = @Fecha;";
|
||||
return await connection.QueryAsync<CotizacionGanado>(sql, new { Fecha = fecha.ToString("yyyy-MM-dd") });
|
||||
}
|
||||
|
||||
// Nuevo método para hacer un "reemplazo" inteligente
|
||||
public async Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones)
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// Borramos solo los registros del día que vamos a actualizar
|
||||
const string deleteSql = "DELETE FROM CotizacionesGanado WHERE CONVERT(date, FechaRegistro) = @Fecha;";
|
||||
await connection.ExecuteAsync(deleteSql, new { Fecha = fecha.ToString("yyyy-MM-dd") }, transaction);
|
||||
|
||||
// Insertamos la nueva tanda completa
|
||||
const string insertSql = @"
|
||||
INSERT INTO CotizacionesGanado (Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro)
|
||||
VALUES (@Categoria, @Especificaciones, @Maximo, @Minimo, @Promedio, @Mediano, @Cabezas, @KilosTotales, @KilosPorCabeza, @ImporteTotal, @FechaRegistro);";
|
||||
await connection.ExecuteAsync(insertSql, nuevasCotizaciones, transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones)
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
@@ -30,19 +83,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
|
||||
await connection.ExecuteAsync(sql, cotizaciones);
|
||||
}
|
||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync()
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
|
||||
// Primero, obtenemos la fecha de registro más reciente.
|
||||
// Luego, seleccionamos todos los registros que tengan esa fecha.
|
||||
const string sql = @"
|
||||
SELECT *
|
||||
FROM CotizacionesGanado
|
||||
WHERE FechaRegistro = (SELECT MAX(FechaRegistro) FROM CotizacionesGanado);";
|
||||
|
||||
return await connection.QueryAsync<CotizacionGanado>(sql);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias)
|
||||
{
|
||||
@@ -60,10 +100,11 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
ORDER BY
|
||||
FechaRegistro ASC;";
|
||||
|
||||
return await connection.QueryAsync<CotizacionGanado>(sql, new {
|
||||
Categoria = categoria,
|
||||
Especificaciones = especificaciones,
|
||||
Dias = dias
|
||||
return await connection.QueryAsync<CotizacionGanado>(sql, new
|
||||
{
|
||||
Categoria = categoria,
|
||||
Especificaciones = especificaciones,
|
||||
Dias = dias
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
{
|
||||
public interface ICotizacionGanadoRepository : IBaseRepository
|
||||
{
|
||||
Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones);
|
||||
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync();
|
||||
Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync();
|
||||
Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones);
|
||||
Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias);
|
||||
Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Mercados.Core.Entities;
|
||||
|
||||
namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
{
|
||||
public interface IMercadoFeriadoRepository : IBaseRepository
|
||||
{
|
||||
Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio);
|
||||
Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Dapper;
|
||||
using Mercados.Core.Entities;
|
||||
using System.Data;
|
||||
|
||||
namespace Mercados.Infrastructure.Persistence.Repositories
|
||||
{
|
||||
public class MercadoFeriadoRepository : IMercadoFeriadoRepository
|
||||
{
|
||||
private readonly IDbConnectionFactory _connectionFactory;
|
||||
|
||||
public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio)
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
const string sql = @"
|
||||
SELECT * FROM MercadosFeriados
|
||||
WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;";
|
||||
return await connection.QueryAsync<MercadoFeriado>(sql, new { CodigoMercado = codigoMercado, Anio = anio });
|
||||
}
|
||||
|
||||
public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados)
|
||||
{
|
||||
using IDbConnection connection = _connectionFactory.CreateConnection();
|
||||
connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
// Borramos todos los feriados del año en curso para ese mercado
|
||||
var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year;
|
||||
if (anio.HasValue)
|
||||
{
|
||||
const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;";
|
||||
await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado, Anio = anio.Value }, transaction);
|
||||
}
|
||||
|
||||
// Insertamos los nuevos
|
||||
const string insertSql = @"
|
||||
INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre)
|
||||
VALUES (@CodigoMercado, @Fecha, @Nombre);";
|
||||
await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction);
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Mercados.Core.Entities;
|
||||
using Mercados.Infrastructure.Persistence.Repositories;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Mercados.Infrastructure.Services
|
||||
{
|
||||
public class FinnhubHolidayService : IHolidayService
|
||||
{
|
||||
private readonly IMercadoFeriadoRepository _feriadoRepository;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<FinnhubHolidayService> _logger;
|
||||
|
||||
public FinnhubHolidayService(
|
||||
IMercadoFeriadoRepository feriadoRepository,
|
||||
IMemoryCache cache,
|
||||
ILogger<FinnhubHolidayService> logger)
|
||||
{
|
||||
_feriadoRepository = feriadoRepository;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date)
|
||||
{
|
||||
var dateOnly = DateOnly.FromDateTime(date);
|
||||
var cacheKey = $"holidays_{marketCode}_{date.Year}";
|
||||
|
||||
if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays))
|
||||
{
|
||||
_logger.LogInformation("Caché de feriados no encontrada para {MarketCode}. Obteniendo desde la base de datos.", marketCode);
|
||||
|
||||
try
|
||||
{
|
||||
// Llama a NUESTRA base de datos, no a la API externa.
|
||||
var feriadosDesdeDb = await _feriadoRepository.ObtenerPorMercadoYAnioAsync(marketCode, date.Year);
|
||||
holidays = feriadosDesdeDb.Select(h => DateOnly.FromDateTime(h.Fecha)).ToHashSet();
|
||||
_cache.Set(cacheKey, holidays, TimeSpan.FromHours(24));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "No se pudo obtener la lista de feriados para {MarketCode} desde la DB.", marketCode);
|
||||
return false; // Asumimos que no es feriado si la DB falla
|
||||
}
|
||||
}
|
||||
|
||||
return holidays?.Contains(dateOnly) ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Mercados.Infrastructure/Services/IHolidayService.cs
Normal file
16
src/Mercados.Infrastructure/Services/IHolidayService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace Mercados.Infrastructure.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Define un servicio para consultar si una fecha es feriado para un mercado.
|
||||
/// </summary>
|
||||
public interface IHolidayService
|
||||
{
|
||||
/// <summary>
|
||||
/// Comprueba si la fecha dada es un feriado bursátil para el mercado especificado.
|
||||
/// </summary>
|
||||
/// <param name="marketCode">El código del mercado (ej. "BA" para Buenos Aires, "US" para EEUU).</param>
|
||||
/// <param name="date">La fecha a comprobar.</param>
|
||||
/// <returns>True si es feriado, false si no lo es.</returns>
|
||||
Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date);
|
||||
}
|
||||
}
|
||||
@@ -14,22 +14,23 @@ namespace Mercados.Worker
|
||||
private readonly ILogger<DataFetchingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly TimeZoneInfo _argentinaTimeZone;
|
||||
|
||||
// Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo.
|
||||
|
||||
// Expresiones Cron
|
||||
private readonly CronExpression _agroSchedule;
|
||||
private readonly CronExpression _bcrSchedule;
|
||||
private readonly CronExpression _bolsasSchedule;
|
||||
private readonly CronExpression _holidaysSchedule;
|
||||
|
||||
// Almacenamos la próxima ejecución calculada para cada tarea.
|
||||
// Próximas ejecuciones
|
||||
private DateTime? _nextAgroRun;
|
||||
private DateTime? _nextBcrRun;
|
||||
private DateTime? _nextBolsasRun;
|
||||
private DateTime? _nextHolidaysRun;
|
||||
|
||||
// Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea.
|
||||
private readonly Dictionary<string, DateTime> _lastAlertSent = new();
|
||||
// Definimos el período de "silencio" para las alertas (ej. 4 horas).
|
||||
private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4);
|
||||
|
||||
// Eliminamos IHolidayService del constructor
|
||||
public DataFetchingService(
|
||||
ILogger<DataFetchingService> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
@@ -55,6 +56,7 @@ namespace Mercados.Worker
|
||||
_agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!);
|
||||
_bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!);
|
||||
_bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!);
|
||||
_holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,44 +66,86 @@ namespace Mercados.Worker
|
||||
{
|
||||
_logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now);
|
||||
|
||||
// Ejecutamos una vez al inicio para tener datos frescos inmediatamente.
|
||||
await RunAllFetchersAsync(stoppingToken);
|
||||
// La ejecución inicial sigue comentada
|
||||
// await RunAllFetchersAsync(stoppingToken);
|
||||
|
||||
// Calculamos las primeras ejecuciones programadas al arrancar.
|
||||
var utcNow = DateTime.UtcNow;
|
||||
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
_nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
|
||||
// Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes.
|
||||
// Un intervalo más corto aumenta la precisión del disparo de las tareas.
|
||||
// Usamos un PeriodicTimer que "despierta" cada 30 segundos.
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
utcNow = DateTime.UtcNow;
|
||||
var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone);
|
||||
|
||||
// Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea.
|
||||
// Tarea de actualización de Feriados (semanal)
|
||||
if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value)
|
||||
{
|
||||
_logger.LogInformation("Ejecutando tarea semanal de actualización de feriados.");
|
||||
await RunFetcherByNameAsync("Holidays", stoppingToken);
|
||||
_nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
}
|
||||
|
||||
// Tarea de Mercado Agroganadero (diaria)
|
||||
if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value)
|
||||
{
|
||||
await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
|
||||
// Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia.
|
||||
// Comprueba si NO es feriado en Argentina para ejecutar
|
||||
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||
{
|
||||
await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken);
|
||||
}
|
||||
else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); }
|
||||
|
||||
// Recalcula la próxima ejecución sin importar si corrió o fue feriado
|
||||
_nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
}
|
||||
|
||||
// Tarea de Granos BCR (diaria)
|
||||
if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value)
|
||||
{
|
||||
await RunFetcherByNameAsync("BCR", stoppingToken);
|
||||
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||
{
|
||||
await RunFetcherByNameAsync("BCR", stoppingToken);
|
||||
}
|
||||
else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); }
|
||||
|
||||
_nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
}
|
||||
|
||||
// Tarea de Bolsas (recurrente)
|
||||
if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value)
|
||||
{
|
||||
_logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo...");
|
||||
await Task.WhenAll(
|
||||
RunFetcherByNameAsync("YahooFinance", stoppingToken),
|
||||
RunFetcherByNameAsync("Finnhub", stoppingToken)
|
||||
);
|
||||
_logger.LogInformation("Ventana de ejecución para Bolsas detectada.");
|
||||
|
||||
var bolsaTasks = new List<Task>();
|
||||
|
||||
// Comprueba el mercado local (Argentina)
|
||||
if (!await IsMarketHolidayAsync("BA", nowInArgentina))
|
||||
{
|
||||
bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken));
|
||||
}
|
||||
else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); }
|
||||
|
||||
// Comprueba el mercado de EEUU
|
||||
if (!await IsMarketHolidayAsync("US", nowInArgentina))
|
||||
{
|
||||
bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken));
|
||||
}
|
||||
else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); }
|
||||
|
||||
// Si hay alguna tarea para ejecutar, las lanza en paralelo
|
||||
if (bolsaTasks.Any())
|
||||
{
|
||||
_logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count);
|
||||
await Task.WhenAll(bolsaTasks);
|
||||
}
|
||||
|
||||
_nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone);
|
||||
}
|
||||
}
|
||||
@@ -179,5 +223,14 @@ namespace Mercados.Worker
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// Creamos una única función para comprobar feriados que obtiene el servicio
|
||||
// desde un scope.
|
||||
private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var holidayService = scope.ServiceProvider.GetRequiredService<IHolidayService>();
|
||||
return await holidayService.IsMarketHolidayAsync(marketCode, date);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/Mercados.Worker/Dockerfile
Normal file
31
src/Mercados.Worker/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# --- Etapa 1: Build ---
|
||||
# Usamos la imagen del SDK completa para tener todas las herramientas de compilación
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copiamos los archivos .csproj y restauramos las dependencias
|
||||
COPY ["src/Mercados.Worker/Mercados.Worker.csproj", "Mercados.Worker/"]
|
||||
COPY ["src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", "Mercados.Infrastructure/"]
|
||||
COPY ["src/Mercados.Core/Mercados.Core.csproj", "Mercados.Core/"]
|
||||
RUN dotnet restore "Mercados.Worker/Mercados.Worker.csproj"
|
||||
|
||||
# Copiamos el resto del código fuente
|
||||
COPY src/. .
|
||||
|
||||
# Publicamos la aplicación en modo Release
|
||||
WORKDIR "/src/Mercados.Worker"
|
||||
RUN dotnet publish "Mercados.Worker.csproj" -c Release -o /app/publish
|
||||
|
||||
# --- Etapa 2: Final ---
|
||||
# Usamos la imagen de runtime, que es más ligera
|
||||
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS final
|
||||
|
||||
# Instalamos las librerías de soporte para globalización e ICU (International Components for Unicode)
|
||||
# Esto es necesario en imágenes de Linux minimalistas para poder usar codificaciones no-UTF8 como windows-1252.
|
||||
RUN apt-get update && apt-get install -y libicu-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# El punto de entrada no cambia
|
||||
ENTRYPOINT ["dotnet", "Mercados.Worker.dll"]
|
||||
@@ -31,6 +31,13 @@ IHost host = Host.CreateDefaultBuilder(args)
|
||||
services.AddHttpClient("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||
services.AddHttpClient("FinnhubDataFetcher").AddPolicyHandler(GetRetryPolicy());
|
||||
|
||||
// Servicio de caché en memoria de .NET
|
||||
services.AddMemoryCache();
|
||||
// Registramos nuestro nuevo servicio de feriados
|
||||
services.AddScoped<IHolidayService, FinnhubHolidayService>();
|
||||
services.AddScoped<IDataFetcher, HolidayDataFetcher>();
|
||||
services.AddScoped<IMercadoFeriadoRepository, MercadoFeriadoRepository>();
|
||||
|
||||
services.AddHostedService<DataFetchingService>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Debug",
|
||||
"Microsoft.Hosting.Lifetime": "Information"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
"DefaultConnection": ""
|
||||
},
|
||||
"Schedules": {
|
||||
"MercadoAgroganadero": "0 11 * * 1-5",
|
||||
"MercadoAgroganadero": "0 11,15,18,21 * * 1-5",
|
||||
"BCR": "30 11 * * 1-5",
|
||||
"Bolsas": "10 11-17 * * 1-5"
|
||||
"Bolsas": "10 11-17 * * 1-5",
|
||||
"Holidays": "0 2 * * 1"
|
||||
},
|
||||
"ApiKeys": {
|
||||
"Finnhub": "",
|
||||
|
||||
Reference in New Issue
Block a user