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/*.hhp | ||||||
| DocProject/Help/Html2 | DocProject/Help/Html2 | ||||||
| DocProject/Help/html | DocProject/Help/html | ||||||
|  | # DocFx | ||||||
|  | [Dd]ocs/ | ||||||
|  |  | ||||||
| # Click-Once directory | # Click-Once directory | ||||||
| publish/ | publish/ | ||||||
| @@ -414,3 +416,6 @@ FodyWeavers.xsd | |||||||
| # Built Visual Studio Code Extensions | # Built Visual Studio Code Extensions | ||||||
| *.vsix | *.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'; | import axios from 'axios'; | ||||||
|  |  | ||||||
| // Durante el desarrollo, nuestra API corre en un puerto específico (ej. 5045). | // Eliminamos la baseURL de aquí para evitar cualquier confusión. | ||||||
| // 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'; |  | ||||||
|  |  | ||||||
| const apiClient = axios.create({ | const apiClient = axios.create({ | ||||||
|   baseURL: API_BASE_URL, |  | ||||||
|   headers: { |   headers: { | ||||||
|     'Content-Type': 'application/json', |     'Content-Type': 'application/json', | ||||||
|   }, |   }, | ||||||
|   | |||||||
| @@ -2,15 +2,15 @@ import { Box, CircularProgress, Alert } from '@mui/material'; | |||||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | import type { CotizacionGanado } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||||
|  | import { formatFullDateTime } from '../utils/formatters'; | ||||||
|  |  | ||||||
| interface AgroHistoricalChartWidgetProps { | interface AgroHistoricalChartWidgetProps { | ||||||
|   categoria: string; |   categoria: string; | ||||||
|   especificaciones: string; |   especificaciones: string; | ||||||
| } | } | ||||||
|  |  | ||||||
| const formatXAxis = (tickItem: string) => { | const formatTooltipLabel = (label: string) => { | ||||||
|     const date = new Date(tickItem); |     return formatFullDateTime(label); | ||||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const AgroHistoricalChartWidget = ({ categoria, especificaciones }: AgroHistoricalChartWidgetProps) => { | 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>; |         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 ( |     return ( | ||||||
|         <ResponsiveContainer width="100%" height={300}> |         <ResponsiveContainer width="100%" height={300}> | ||||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> |             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||||
|                 <CartesianGrid strokeDasharray="3 3" /> |                 <CartesianGrid strokeDasharray="3 3" /> | ||||||
|                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> |                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> | ||||||
|                 <YAxis domain={['dataMin - 10', 'dataMax + 10']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> |                 <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 /> |                 <Legend /> | ||||||
|                 <Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} /> |                 <Line type="monotone" dataKey="promedio" name="Precio Promedio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||||
|             </LineChart> |             </LineChart> | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useState } from 'react'; | import React, { useState, useRef } from 'react'; | ||||||
| import { | import { | ||||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, |   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, | ||||||
|   TableHead, TableRow, Paper, Typography, Dialog, DialogTitle, |   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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
| import { formatFullDateTime, 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'; | 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 Variacion = ({ value }: { value: number }) => { | ||||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; |   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; |   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; | ||||||
|   return ( |   return ( | ||||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> |     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> |       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> |       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para la tabla de acciones líderes locales (Panel Merval). | ||||||
|  |  */ | ||||||
| export const BolsaLocalWidget = () => { | 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 [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||||
|  |   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||||
|  |  | ||||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); |   const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => { | ||||||
|   const handleCloseDialog = () => setSelectedTicker(null); |     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') || []; |   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>; |     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (error) { |   if (dataError) { | ||||||
|     return <Alert severity="error">{error}</Alert>; |     return <Alert severity="error">{dataError}</Alert>; | ||||||
|   } |   } | ||||||
|  |    | ||||||
|   if (!data || data.length === 0) { |   // Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado. | ||||||
|     return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>; |   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 ( |   return ( | ||||||
|     <> |     <Box> | ||||||
|  |       {/* La alerta de feriado también se aplica a esta tabla. */} | ||||||
|       {panelPrincipal.length > 0 && ( |       {isHoliday && ( | ||||||
|         <TableContainer component={Paper}> |         <Box sx={{ mb: 2 }}> | ||||||
|           <Box sx={{ p: 1, m: 0 }}> |           <HolidayAlert /> | ||||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> |         </Box> | ||||||
|               Ú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> |  | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|  |       <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 |       <Dialog | ||||||
|         open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth |         open={Boolean(selectedTicker)} | ||||||
|  |         onClose={handleCloseDialog} | ||||||
|  |         maxWidth="md" | ||||||
|  |         fullWidth | ||||||
|         sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} |         sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||||
|       > |       > | ||||||
|         <IconButton |         <IconButton | ||||||
|           aria-label="close" onClick={handleCloseDialog} |           aria-label="close" | ||||||
|  |           onClick={handleCloseDialog} | ||||||
|           sx={{ |           sx={{ | ||||||
|             position: 'absolute', top: -15, right: -15, color: (theme) => theme.palette.grey[500], |             position: 'absolute', top: -15, right: -15, | ||||||
|             backgroundColor: 'white', boxShadow: 3, '&:hover': { backgroundColor: 'grey.100' }, |             color: (theme) => theme.palette.grey[500], | ||||||
|  |             backgroundColor: 'white', boxShadow: 3, | ||||||
|  |             '&:hover': { backgroundColor: 'grey.100' }, | ||||||
|           }} |           }} | ||||||
|         > |         > | ||||||
|           <CloseIcon /> |           <CloseIcon /> | ||||||
|         </IconButton> |         </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> |         <DialogContent dividers> | ||||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />} |           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="Local" dias={30} />} | ||||||
|         </DialogContent> |         </DialogContent> | ||||||
|       </Dialog> |       </Dialog> | ||||||
|     </> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useState } from 'react'; | import React, { useState, useRef } from 'react'; | ||||||
| import { | import { | ||||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, |   Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, | ||||||
|   TableHead, TableRow, Paper, Typography, Dialog, DialogTitle, |   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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
| import { formatFullDateTime, 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'; | 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 Variacion = ({ value }: { value: number }) => { | ||||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; |   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; |   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; | ||||||
|   return ( |   return ( | ||||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> |     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> |       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> |       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para la tabla de acciones de EEUU y ADRs Argentinos. | ||||||
|  |  */ | ||||||
| export const BolsaUsaWidget = () => { | 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 [selectedTicker, setSelectedTicker] = useState<string | null>(null); | ||||||
|  |   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||||
|  |  | ||||||
|   const handleRowClick = (ticker: string) => setSelectedTicker(ticker); |   const handleOpenModal = (ticker: string, event: React.MouseEvent<HTMLButtonElement>) => { | ||||||
|   const handleCloseDialog = () => setSelectedTicker(null); |     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') || []; |   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>; |     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (error) { |   if (dataError) { | ||||||
|     return <Alert severity="error">{error}</Alert>; |     return <Alert severity="error">{dataError}</Alert>; | ||||||
|   } |   } | ||||||
|  |    | ||||||
|   if (!data || data.length === 0) { |   // Si después de filtrar no queda ninguna acción, mostramos el mensaje apropiado. | ||||||
|     return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>; |   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 ( |   return ( | ||||||
|     <> |     <Box> | ||||||
|       {/* Renderizamos la tabla solo si hay otras acciones */} |       {/* Si es feriado, mostramos la alerta informativa en la parte superior. */} | ||||||
|       {otherStocks.length > 0 && ( |       {isHoliday && ( | ||||||
|         <TableContainer component={Paper}> |         <Box sx={{ mb: 2 }}> | ||||||
|           <Box sx={{ p: 1, m: 0 }}> |           <HolidayAlert /> | ||||||
|             <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> |         </Box> | ||||||
|               Ú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> |  | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       <Dialog open={Boolean(selectedTicker)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> |       <TableContainer component={Paper}> | ||||||
|         <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' }, }}> |         <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 /> |           <CloseIcon /> | ||||||
|         </IconButton> |         </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> |         <DialogContent dividers> | ||||||
|           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />} |           {selectedTicker && <HistoricalChartWidget ticker={selectedTicker} mercado="EEUU" dias={30} />} | ||||||
|         </DialogContent> |         </DialogContent> | ||||||
|       </Dialog> |       </Dialog> | ||||||
|     </> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| @@ -2,11 +2,16 @@ import { Box, CircularProgress, Alert } from '@mui/material'; | |||||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | import type { CotizacionGrano } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||||
|  | import { formatFullDateTime } from '../utils/formatters'; | ||||||
|  |  | ||||||
| interface GrainsHistoricalChartWidgetProps { | interface GrainsHistoricalChartWidgetProps { | ||||||
|   nombre: string; |   nombre: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const formatTooltipLabel = (label: string) => { | ||||||
|  |     return formatFullDateTime(label); | ||||||
|  | }; | ||||||
|  |  | ||||||
| const formatXAxis = (tickItem: string) => { | const formatXAxis = (tickItem: string) => { | ||||||
|     const date = new Date(tickItem); |     const date = new Date(tickItem); | ||||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); |     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}> |         <ResponsiveContainer width="100%" height={300}> | ||||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> |             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||||
|                 <CartesianGrid strokeDasharray="3 3" /> |                 <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} /> |                 <XAxis dataKey="fechaOperacion" tickFormatter={formatXAxis} /> | ||||||
|                 <YAxis domain={['dataMin - 1000', 'dataMax + 1000']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> |                 <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 /> |                 <Legend /> | ||||||
|                 <Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} /> |                 <Line type="monotone" dataKey="precio" name="Precio" stroke="#028fbe" strokeWidth={2} dot={false} /> | ||||||
|             </LineChart> |             </LineChart> | ||||||
|   | |||||||
| @@ -8,13 +8,17 @@ import CloseIcon from '@mui/icons-material/Close'; | |||||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
| import { GiSunflower, GiWheat, GiCorn, GiGrain } from "react-icons/gi"; | import { GiSunflower, GiWheat, GiCorn, GiGrainBundle } from "react-icons/gi"; | ||||||
| import { TbGrain } from "react-icons/tb"; | import { TbGrain } from "react-icons/tb"; | ||||||
|  |  | ||||||
| import type { CotizacionGrano } from '../models/mercadoModels'; | import type { CotizacionGrano } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { formatInteger, formatDateOnly } from '../utils/formatters'; | import { formatInteger, formatDateOnly } from '../utils/formatters'; | ||||||
| import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; | import { GrainsHistoricalChartWidget } from './GrainsHistoricalChartWidget'; | ||||||
|  | import { LuBean } from 'react-icons/lu'; | ||||||
|  |  | ||||||
|  | import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||||
|  | import { HolidayAlert } from './common/HolidayAlert'; | ||||||
|  |  | ||||||
| const getGrainIcon = (nombre: string) => { | const getGrainIcon = (nombre: string) => { | ||||||
|   switch (nombre.toLowerCase()) { |   switch (nombre.toLowerCase()) { | ||||||
| @@ -22,7 +26,8 @@ const getGrainIcon = (nombre: string) => { | |||||||
|     case 'trigo': return <GiWheat size={28} color="#fbc02d" />; |     case 'trigo': return <GiWheat size={28} color="#fbc02d" />; | ||||||
|     case 'sorgo': return <TbGrain size={28} color="#fbc02d" />; |     case 'sorgo': return <TbGrain size={28} color="#fbc02d" />; | ||||||
|     case 'maiz': return <GiCorn 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={{ |       sx={{ | ||||||
|         position: 'relative', |         position: 'relative', | ||||||
|         p: 2, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', |         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'}` |         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 }}> |       <Box sx={{ display: 'flex', alignItems: 'center', mb: 1, pr: 5 }}> | ||||||
|         {getGrainIcon(grano.nombre)} |         {getGrainIcon(grano.nombre)} | ||||||
|         <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}> |         <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', ml: 1 }}> | ||||||
|             {grano.nombre} |           {grano.nombre} | ||||||
|         </Typography> |         </Typography> | ||||||
|       </Box> |       </Box> | ||||||
|  |  | ||||||
| @@ -94,6 +99,7 @@ const GranoCard = ({ grano, onChartClick }: { grano: CotizacionGrano, onChartCli | |||||||
|  |  | ||||||
| export const GranosCardWidget = () => { | export const GranosCardWidget = () => { | ||||||
|   const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); |   const { data, loading, error } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||||
|  |   const isHoliday = useIsHoliday('BA'); | ||||||
|   const [selectedGrano, setSelectedGrano] = useState<string | null>(null); |   const [selectedGrano, setSelectedGrano] = useState<string | null>(null); | ||||||
|   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); |   const triggerButtonRef = useRef<HTMLButtonElement | null>(null); | ||||||
|  |  | ||||||
| @@ -105,11 +111,12 @@ export const GranosCardWidget = () => { | |||||||
|   const handleCloseDialog = () => { |   const handleCloseDialog = () => { | ||||||
|     setSelectedGrano(null); |     setSelectedGrano(null); | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|         triggerButtonRef.current?.focus(); |       triggerButtonRef.current?.focus(); | ||||||
|     }, 0); |     }, 0); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   if (loading) { |   if (loading) { | ||||||
|  |     // El spinner de carga sigue siendo prioritario | ||||||
|     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; |     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -123,41 +130,47 @@ export const GranosCardWidget = () => { | |||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|     <Box |       {/* Si es feriado (y la comprobación ha terminado), mostramos la alerta encima */} | ||||||
|       sx={{ |       {isHoliday === true && ( | ||||||
|         display: 'flex', |         <Box sx={{ mb: 2 }}> {/* Añadimos un margen inferior a la alerta */} | ||||||
|         flexWrap: 'wrap', |           <HolidayAlert /> | ||||||
|         // Usamos el objeto para definir gaps responsivos |         </Box> | ||||||
|         gap: { |       )} | ||||||
|           xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical) |       <Box | ||||||
|           sm: 4, // 16px en pantallas pequeñas |         sx={{ | ||||||
|           md: 3, // 24px en pantallas medianas (afecta el espaciado horizontal) |           display: 'flex', | ||||||
|         }, |           flexWrap: 'wrap', | ||||||
|         justifyContent: 'center' |           // Usamos el objeto para definir gaps responsivos | ||||||
|       }} |           gap: { | ||||||
|     > |             xs: 4, // 16px de gap en pantallas extra pequeñas (afecta el espaciado vertical) | ||||||
|       {data.map((grano) => ( |             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)} /> |           <GranoCard key={grano.nombre} grano={grano} onChartClick={(event) => handleChartClick(grano.nombre, event)} /> | ||||||
|         ))} |         ))} | ||||||
|       </Box> |       </Box> | ||||||
|       <Dialog open={Boolean(selectedGrano)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> |       <Dialog open={Boolean(selectedGrano)} onClose={handleCloseDialog} maxWidth="md" fullWidth sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }}> | ||||||
|         <IconButton |         <IconButton | ||||||
|                     aria-label="close" |           aria-label="close" | ||||||
|                     onClick={handleCloseDialog} |           onClick={handleCloseDialog} | ||||||
|                     sx={{ |           sx={{ | ||||||
|                         position: 'absolute', |             position: 'absolute', | ||||||
|                         top: -15, // Mueve el botón hacia arriba, fuera del Dialog |             top: -15, // Mueve el botón hacia arriba, fuera del Dialog | ||||||
|                         right: -15, // Mueve el botón hacia la derecha, fuera del Dialog |             right: -15, // Mueve el botón hacia la derecha, fuera del Dialog | ||||||
|                         color: (theme) => theme.palette.grey[500], |             color: (theme) => theme.palette.grey[500], | ||||||
|                         backgroundColor: 'white', |             backgroundColor: 'white', | ||||||
|                         boxShadow: 3, // Añade una sombra para que destaque |             boxShadow: 3, // Añade una sombra para que destaque | ||||||
|                         '&:hover': { |             '&:hover': { | ||||||
|                             backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse |               backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse | ||||||
|                         }, |             }, | ||||||
|                     }} |           }} | ||||||
|                 > |         > | ||||||
|                     <CloseIcon /> |           <CloseIcon /> | ||||||
|                 </IconButton> |         </IconButton> | ||||||
|         <DialogTitle sx={{ m: 0, p: 2 }}>Mensual de {selectedGrano}</DialogTitle> |         <DialogTitle sx={{ m: 0, p: 2 }}>Mensual de {selectedGrano}</DialogTitle> | ||||||
|         <DialogContent dividers> |         <DialogContent dividers> | ||||||
|           {selectedGrano && <GrainsHistoricalChartWidget nombre={selectedGrano} />} |           {selectedGrano && <GrainsHistoricalChartWidget nombre={selectedGrano} />} | ||||||
|   | |||||||
| @@ -2,19 +2,20 @@ import { | |||||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, |   Box, CircularProgress, Alert, Table, TableBody, TableCell, | ||||||
|   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip |   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import type { CotizacionGrano } from '../models/mercadoModels'; |  | ||||||
| import { useApiData } from '../hooks/useApiData'; |  | ||||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
|  |  | ||||||
| const formatNumber = (num: number) => { | // Importaciones de nuestro proyecto | ||||||
|   return new Intl.NumberFormat('es-AR', { | import type { CotizacionGrano } from '../models/mercadoModels'; | ||||||
|     minimumFractionDigits: 0, | import { useApiData } from '../hooks/useApiData'; | ||||||
|     maximumFractionDigits: 2, | import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||||
|   }).format(num); | 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 Variacion = ({ value }: { value: number }) => { | ||||||
|   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; |   const color = value > 0 ? 'success.main' : value < 0 ? 'error.main' : 'text.secondary'; | ||||||
|   const Icon = value > 0 ? ArrowUpwardIcon : value < 0 ? ArrowDownwardIcon : RemoveIcon; |   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 }}> |     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> |       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}> |       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}> | ||||||
|         {formatNumber(value)} |         {formatInteger(value)} | ||||||
|       </Typography> |       </Typography> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para la tabla detallada del mercado de granos. | ||||||
|  |  */ | ||||||
| export const GranosWidget = () => { | 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>; |     return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   if (error) { |   if (dataError) { | ||||||
|     return <Alert severity="error">{error}</Alert>; |     return <Alert severity="error">{dataError}</Alert>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   // Si no hay ningún dato que mostrar. | ||||||
|   if (!data || data.length === 0) { |   if (!data || data.length === 0) { | ||||||
|  |     if (isHoliday) { | ||||||
|  |       return <HolidayAlert />; | ||||||
|  |     } | ||||||
|     return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>; |     return <Alert severity="info">No hay datos de granos disponibles en este momento.</Alert>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <TableContainer component={Paper}> |     <Box> | ||||||
|       <Table size="small" aria-label="tabla granos"> |       {/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */} | ||||||
|         <TableHead> |       {isHoliday && ( | ||||||
|           <TableRow> |         <Box sx={{ mb: 2 }}> | ||||||
|             <TableCell>Grano</TableCell> |           <HolidayAlert /> | ||||||
|             <TableCell align="right">Precio ($/Tn)</TableCell> |         </Box> | ||||||
|             <TableCell align="center">Variación</TableCell> |       )} | ||||||
|             <TableCell align="right">Fecha Operación</TableCell> |  | ||||||
|           </TableRow> |       <TableContainer component={Paper}> | ||||||
|         </TableHead> |         <Table size="small" aria-label="tabla granos"> | ||||||
|         <TableBody> |           <TableHead> | ||||||
|           {data.map((row) => ( |             <TableRow> | ||||||
|             <TableRow key={row.nombre} hover> |               <TableCell>Grano</TableCell> | ||||||
|               <TableCell component="th" scope="row"> |               <TableCell align="right">Precio ($/Tn)</TableCell> | ||||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography> |               <TableCell align="center">Variación</TableCell> | ||||||
|               </TableCell> |               <TableCell align="right">Fecha Operación</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> |  | ||||||
|             </TableRow> |             </TableRow> | ||||||
|           ))} |           </TableHead> | ||||||
|         </TableBody> |           <TableBody> | ||||||
|       </Table> |             {data.map((row) => ( | ||||||
|       <Tooltip title={`Última actualización: ${new Date(data[0].fechaRegistro).toLocaleString('es-AR')}`}> |               <TableRow key={row.nombre} hover> | ||||||
|         <Typography variant="caption" sx={{ p: 1, display: 'block', textAlign: 'right', color: 'text.secondary' }}> |                 <TableCell component="th" scope="row"> | ||||||
|           Fuente: Bolsa de Comercio de Rosario |                   <Typography variant="body2" sx={{ fontWeight: 'bold' }}>{row.nombre}</Typography> | ||||||
|         </Typography> |                 </TableCell> | ||||||
|       </Tooltip> |                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> | ||||||
|     </TableContainer> |                 <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 type { CotizacionBolsa } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; | ||||||
|  | import { formatFullDateTime, formatCurrency2Decimal, formatCurrency } from '../utils/formatters'; | ||||||
|  |  | ||||||
| interface HistoricalChartWidgetProps { | interface HistoricalChartWidgetProps { | ||||||
|     ticker: string; |     ticker: string; | ||||||
| @@ -9,10 +10,13 @@ interface HistoricalChartWidgetProps { | |||||||
|     dias: number; |     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 formatXAxis = (tickItem: string) => { | ||||||
|     const date = new Date(tickItem); |     return new Date(tickItem).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | ||||||
|     return date.toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit' }); | }; | ||||||
|  |  | ||||||
|  | const formatTooltipLabel = (label: string) => { | ||||||
|  |     return formatFullDateTime(label); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => { | export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChartWidgetProps) => { | ||||||
| @@ -24,22 +28,49 @@ export const HistoricalChartWidget = ({ ticker, mercado, dias }: HistoricalChart | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     if (error) { |     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) { |     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 ( |     return ( | ||||||
|         <ResponsiveContainer width="100%" height={300}> |         <ResponsiveContainer width="100%" height={300}> | ||||||
|             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> |             <LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> | ||||||
|                 <CartesianGrid strokeDasharray="3 3" /> |                 <CartesianGrid strokeDasharray="3 3" /> | ||||||
|                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> |                 <XAxis dataKey="fechaRegistro" tickFormatter={formatXAxis} /> | ||||||
|                 <YAxis domain={['dataMin - 1', 'dataMax + 1']} tickFormatter={(tick) => `$${tick.toLocaleString('es-AR')}`} /> |                 <YAxis | ||||||
|                 <Tooltip formatter={(value: number) => [`$${value.toFixed(2)}`, 'Precio']} /> |                     domain={[domainMin, domainMax]} | ||||||
|  |                     tickFormatter={yAxisTickFormatter} | ||||||
|  |                     width={dynamicWidth} | ||||||
|  |                     tickMargin={5} | ||||||
|  |                 /> | ||||||
|  |                 <Tooltip | ||||||
|  |                     formatter={(value: number) => [`${formatCurrency2Decimal(value, mercado === 'EEUU' ? 'USD' : 'ARS')}`, 'Precio']} | ||||||
|  |                     labelFormatter={formatTooltipLabel} | ||||||
|  |                 /> | ||||||
|                 <Legend /> |                 <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> |             </LineChart> | ||||||
|         </ResponsiveContainer> |         </ResponsiveContainer> | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { useState } from 'react'; | import React, { useState, useRef } from 'react'; | ||||||
| import { | import { | ||||||
|     Box, CircularProgress, Alert, Paper, Typography, Dialog, |     Box, CircularProgress, Alert, Paper, Typography, Dialog, | ||||||
|     DialogTitle, DialogContent, IconButton |     DialogTitle, DialogContent, IconButton | ||||||
| @@ -8,131 +8,159 @@ import ScaleIcon from '@mui/icons-material/Scale'; | |||||||
| import { PiChartLineUpBold } from "react-icons/pi"; | import { PiChartLineUpBold } from "react-icons/pi"; | ||||||
| import CloseIcon from '@mui/icons-material/Close'; | import CloseIcon from '@mui/icons-material/Close'; | ||||||
|  |  | ||||||
|  | // Importaciones de nuestro proyecto | ||||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | import type { CotizacionGanado } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { formatCurrency, formatInteger } from '../utils/formatters'; | import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||||
|  | import { formatCurrency, formatInteger, formatDateOnly } from '../utils/formatters'; | ||||||
| import { AgroHistoricalChartWidget } from './AgroHistoricalChartWidget'; | 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 ( |     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', display: 'flex', flexDirection: 'column' }}> | ||||||
|         <Paper elevation={2} sx={{ p: 2, flex: '1 1 250px', minWidth: '250px', maxWidth: '300px', position: 'relative' }}> |             {/* Contenido principal de la tarjeta */} | ||||||
|             <IconButton |             <Box sx={{ flexGrow: 1 }}> | ||||||
|                 aria-label="ver historial" |                 <IconButton | ||||||
|                 onClick={(e) => { |                     aria-label="ver historial" | ||||||
|                     e.stopPropagation(); |                     onClick={onChartClick} | ||||||
|                     onChartClick(); |                     sx={{ | ||||||
|                 }} |                         position: 'absolute', top: 8, right: 8, | ||||||
|                 sx={{ |                         backgroundColor: 'rgba(255, 255, 255, 0.7)', | ||||||
|                     position: 'absolute', |                         backdropFilter: 'blur(2px)', | ||||||
|                     top: 8, |                         border: '1px solid rgba(0, 0, 0, 0.1)', | ||||||
|                     right: 8, |                         boxShadow: '0 2px 5px rgba(0,0,0,0.1)', | ||||||
|                     backgroundColor: 'rgba(255, 255, 255, 0.7)', // Fondo semitransparente |                         transition: 'all 0.2s ease-in-out', | ||||||
|                     backdropFilter: 'blur(2px)', // Efecto "frosty glass" para el fondo |                         '&:hover': { | ||||||
|                     border: '1px solid rgba(0, 0, 0, 0.1)', |                             transform: 'translateY(-2px)', | ||||||
|                     boxShadow: '0 2px 5px rgba(0,0,0,0.1)', |                             boxShadow: '0 4px 10px rgba(0,0,0,0.2)', | ||||||
|                     transition: 'all 0.2s ease-in-out', // Transición suave para todos los cambios |                             backgroundColor: 'rgba(255, 255, 255, 0.9)', | ||||||
|                     '&: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> | ||||||
|                 }} |  | ||||||
|             > |  | ||||||
|                 <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 */ }}> |                 <Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', borderBottom: 1, borderColor: 'divider', pb: 1, mb: 2, pr: 5 }}> | ||||||
|                 {registro.categoria} |                     {registro.categoria} | ||||||
|                 <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> |                     <Typography variant="body2" color="text.secondary">{registro.especificaciones}</Typography> | ||||||
|             </Typography> |                 </Typography> | ||||||
|  |  | ||||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> |                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||||
|                 <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> |                     <Typography variant="body2" color="text.secondary">Precio Máximo:</Typography> | ||||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> |                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'success.main' }}>${formatCurrency(registro.maximo)}</Typography> | ||||||
|             </Box> |  | ||||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> |  | ||||||
|                 <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> |  | ||||||
|                 <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(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> |                 </Box> | ||||||
|                 <Box sx={{ textAlign: 'center' }}> |                 <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}> | ||||||
|                     <ScaleIcon color="action" /> |                     <Typography variant="body2" color="text.secondary">Precio Mínimo:</Typography> | ||||||
|                     <Typography variant="body1" sx={{ fontWeight: 'bold' }}>{formatInteger(registro.kilosTotales)}</Typography> |                     <Typography variant="body2" sx={{ fontWeight: 'bold', color: 'error.main' }}>${formatCurrency(registro.minimo)}</Typography> | ||||||
|                     <Typography variant="caption" color="text.secondary">Kilos</Typography> |  | ||||||
|                 </Box> |                 </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> |             </Box> | ||||||
|         </Paper> |         </Paper> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para las tarjetas de resumen del Mercado Agroganadero. | ||||||
|  |  */ | ||||||
| export const MercadoAgroCardWidget = () => { | export const MercadoAgroCardWidget = () => { | ||||||
|     const { data, loading, error } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); |     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||||
|     const [selectedCategory, setSelectedCategory] = useState<CotizacionGanado | null>(null); |     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); |         setSelectedCategory(registro); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     const handleCloseDialog = () => { |     const handleCloseDialog = () => { | ||||||
|         setSelectedCategory(null); |         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>; |         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 (!data || data.length === 0) { | ||||||
|  |         if (isHoliday) { | ||||||
|  |             return <HolidayAlert />; | ||||||
|  |         } | ||||||
|         return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; |         return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     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 => ( |                 {data.map(registro => ( | ||||||
|                     <AgroCard key={registro.id} registro={registro} onChartClick={() => handleChartClick(registro)} /> |                     <AgroCard key={registro.id} registro={registro} onChartClick={(event) => handleChartClick(registro, event)} /> | ||||||
|                 ))} |                 ))} | ||||||
|             </Box> |             </Box> | ||||||
|  |  | ||||||
|             <Dialog |             <Dialog | ||||||
|                 open={Boolean(selectedCategory)} |                 open={Boolean(selectedCategory)} | ||||||
|                 onClose={handleCloseDialog} |                 onClose={handleCloseDialog} | ||||||
|                 maxWidth="md" |                 maxWidth="md" | ||||||
|                 fullWidth |                 fullWidth | ||||||
|                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} // Permite que el botón se vea fuera |                 sx={{ '& .MuiDialog-paper': { overflow: 'visible' } }} | ||||||
|             > |             > | ||||||
|                 <IconButton |                 <IconButton | ||||||
|                     aria-label="close" |                     aria-label="close" | ||||||
|                     onClick={handleCloseDialog} |                     onClick={handleCloseDialog} | ||||||
|                     sx={{ |                     sx={{ | ||||||
|                         position: 'absolute', |                         position: 'absolute', top: -15, right: -15, | ||||||
|                         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], |                         color: (theme) => theme.palette.grey[500], | ||||||
|                         backgroundColor: 'white', |                         backgroundColor: 'white', boxShadow: 3, | ||||||
|                         boxShadow: 3, // Añade una sombra para que destaque |                         '&:hover': { backgroundColor: 'grey.100' }, | ||||||
|                         '&:hover': { |  | ||||||
|                             backgroundColor: 'grey.100', // Un leve cambio de color al pasar el mouse |  | ||||||
|                         }, |  | ||||||
|                     }} |                     }} | ||||||
|                 > |                 > | ||||||
|                     <CloseIcon /> |                     <CloseIcon /> | ||||||
|                 </IconButton> |                 </IconButton> | ||||||
|  |  | ||||||
|                 <DialogTitle sx={{ m: 0, p: 2 }}> |                 <DialogTitle sx={{ m: 0, p: 2 }}> | ||||||
|                     Mensual de {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) |                     Historial de 30 días para {selectedCategory?.categoria} ({selectedCategory?.especificaciones}) | ||||||
|                 </DialogTitle> |                 </DialogTitle> | ||||||
|                 <DialogContent dividers> |                 <DialogContent dividers> | ||||||
|                     {selectedCategory && ( |                     {selectedCategory && ( | ||||||
|   | |||||||
| @@ -2,13 +2,17 @@ import { | |||||||
|   Box, CircularProgress, Alert, Table, TableBody, TableCell, |   Box, CircularProgress, Alert, Table, TableBody, TableCell, | ||||||
|   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip |   TableContainer, TableHead, TableRow, Paper, Typography, Tooltip | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
|  |  | ||||||
|  | // Importaciones de nuestro proyecto | ||||||
| import type { CotizacionGanado } from '../models/mercadoModels'; | import type { CotizacionGanado } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
| import { formatCurrency, formatInteger, formatFullDateTime } from '../utils/formatters'; | 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. |  * Sub-componente para renderizar cada registro como una tarjeta en la vista móvil. | ||||||
| // La fila de la tabla la haremos directamente en el componente principal. |  */ | ||||||
| const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | ||||||
|     const commonStyles = { |     const commonStyles = { | ||||||
|         cell: { |         cell: { | ||||||
| @@ -56,18 +60,42 @@ const AgroDataCard = ({ row }: { row: CotizacionGanado }) => { | |||||||
|         </Paper> |         </Paper> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| // --- ^ FIN DE LA MODIFICACIÓN ^ --- |  | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para la tabla/lista responsiva del Mercado Agroganadero. | ||||||
|  |  */ | ||||||
| export const MercadoAgroWidget = () => { | 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>; } |   // Estado de carga unificado. | ||||||
|   if (error) { return <Alert severity="error">{error}</Alert>; } |   const isLoading = dataLoading || isHoliday === null; | ||||||
|   if (!data || data.length === 0) { return <Alert severity="info">No hay datos del mercado agroganadero disponibles.</Alert>; } |  | ||||||
|  |   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 ( |   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) */} |       {/* VISTA DE ESCRITORIO (se oculta en móvil) */} | ||||||
|       <TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}> |       <TableContainer component={Paper} sx={{ display: { xs: 'none', md: 'block' } }}> | ||||||
|         <Table size="small" aria-label="tabla mercado agroganadero"> |         <Table size="small" aria-label="tabla mercado agroganadero"> | ||||||
| @@ -107,9 +135,10 @@ export const MercadoAgroWidget = () => { | |||||||
|           ))} |           ))} | ||||||
|       </Box> |       </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' }}> |         <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> |         </Typography> | ||||||
|       </Tooltip> |       </Tooltip> | ||||||
|     </Box> |     </Box> | ||||||
|   | |||||||
| @@ -5,13 +5,17 @@ import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | |||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
|  |  | ||||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | 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 { HistoricalChartWidget } from './HistoricalChartWidget'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | 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 }) => { | 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 variacionPuntos = actual - anterior; | ||||||
|     const variacionPorcentaje = (variacionPuntos / anterior) * 100; |     const variacionPorcentaje = (variacionPuntos / anterior) * 100; | ||||||
|  |  | ||||||
| @@ -34,44 +38,79 @@ const VariacionMerval = ({ actual, anterior }: { actual: number, anterior: numbe | |||||||
|         </Box> |         </Box> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| // --- ^ SUB-COMPONENTE AÑADIDO ^ --- |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Widget autónomo para la tarjeta de héroe del S&P Merval. | ||||||
|  |  */ | ||||||
| export const MervalHeroCard = () => { | 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 [dias, setDias] = useState<number>(30); | ||||||
|  |  | ||||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { |     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } |         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||||
|     }; |     }; | ||||||
|      |      | ||||||
|  |     // Filtramos el dato específico que este widget necesita | ||||||
|     const mervalData = allLocalData?.find(d => d.ticker === '^MERV'); |     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 (isLoading) { | ||||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } |         return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4, height: '288px' }}><CircularProgress /></Box>; | ||||||
|     if (!mervalData) { return <Alert severity="info">No se encontraron datos para el índice MERVAL.</Alert>; } |     } | ||||||
|  |  | ||||||
|  |     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 ( |     return ( | ||||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> |         <Box> | ||||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> |             {/* Si es feriado, mostramos la alerta como un AVISO encima del contenido. */} | ||||||
|                 <Box> |             {isHoliday && ( | ||||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>Índice S&P MERVAL</Typography> |                 <Box sx={{ mb: 2 }}> | ||||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(mervalData.precioActual)}</Typography> |                     <HolidayAlert /> | ||||||
|                 </Box> |                 </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> |                 <Box sx={{ mt: 2 }}> | ||||||
|             <Box sx={{ mt: 2 }}> |                     <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> |                         <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> |                             <ToggleButton value={7}>Semanal</ToggleButton> | ||||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> |                             <ToggleButton value={30}>Mensual</ToggleButton> | ||||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> |                             <ToggleButton value={365}>Anual</ToggleButton> | ||||||
|                         <ToggleButton value={365}>Anual</ToggleButton> |                         </ToggleButtonGroup> | ||||||
|                     </ToggleButtonGroup> |                     </Box> | ||||||
|  |                     <HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} /> | ||||||
|                 </Box> |                 </Box> | ||||||
|                 <HistoricalChartWidget ticker={mervalData.ticker} mercado="Local" dias={dias} /> |             </Paper> | ||||||
|             </Box> |         </Box> | ||||||
|         </Paper> |  | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| @@ -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 { Box, Paper, Typography, ToggleButton, ToggleButtonGroup, CircularProgress, Alert } from '@mui/material'; | ||||||
| import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; | ||||||
| import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; | ||||||
| import RemoveIcon from '@mui/icons-material/Remove'; | import RemoveIcon from '@mui/icons-material/Remove'; | ||||||
|  |  | ||||||
|  | // Importaciones de nuestro proyecto | ||||||
| import type { CotizacionBolsa } from '../models/mercadoModels'; | import type { CotizacionBolsa } from '../models/mercadoModels'; | ||||||
| import { useApiData } from '../hooks/useApiData'; | import { useApiData } from '../hooks/useApiData'; | ||||||
|  | import { useIsHoliday } from '../hooks/useIsHoliday'; | ||||||
| import { formatInteger } from '../utils/formatters'; | import { formatInteger } from '../utils/formatters'; | ||||||
| import { HistoricalChartWidget } from './HistoricalChartWidget'; | 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 }) => { | const VariacionIndex = ({ actual, anterior }: { actual: number, anterior: number }) => { | ||||||
|     if (anterior === 0) return null; |     if (anterior === 0) return null; | ||||||
|     const variacionPuntos = actual - anterior; |     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 = () => { | 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 [dias, setDias] = useState<number>(30); | ||||||
|  |  | ||||||
|     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { |     const handleRangoChange = ( _event: React.MouseEvent<HTMLElement>, nuevoRango: number | null ) => { | ||||||
|         if (nuevoRango !== null) { setDias(nuevoRango); } |         if (nuevoRango !== null) { setDias(nuevoRango); } | ||||||
|     }; |     }; | ||||||
|      |      | ||||||
|  |     // Filtramos el dato específico que este widget necesita. | ||||||
|     const indexData = allUsaData?.find(d => d.ticker === '^GSPC'); |     const indexData = allUsaData?.find(d => d.ticker === '^GSPC'); | ||||||
|  |  | ||||||
|     if (loading) { return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>; } |     // Estado de carga unificado: esperamos a que AMBAS llamadas a la API terminen. | ||||||
|     if (error) { return <Alert severity="error">{error}</Alert>; } |     const isLoading = dataLoading || isHoliday === null; | ||||||
|     if (!indexData) { return <Alert severity="info">No se encontraron datos para el índice S&P 500.</Alert>; } |  | ||||||
|  |  | ||||||
|  |     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 ( |     return ( | ||||||
|         <Paper elevation={3} sx={{ p: 2, mb: 3 }}> |         <Box> | ||||||
|             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}> |             {/* Si es feriado, mostramos la alerta como un aviso encima del contenido. */} | ||||||
|                 <Box> |             {isHoliday && ( | ||||||
|                     <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold' }}>S&P 500 Index</Typography> |                 <Box sx={{ mb: 2 }}> | ||||||
|                     <Typography variant="h3" component="p" sx={{ fontWeight: 'bold', mt:1 }}>{formatInteger(indexData.precioActual)}</Typography> |                     <HolidayAlert /> | ||||||
|                 </Box> |                 </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> |                 <Box sx={{ mt: 2 }}> | ||||||
|             <Box sx={{ mt: 2 }}> |                     <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> | ||||||
|                 <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 1 }}> |                         <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> | ||||||
|                     <ToggleButtonGroup value={dias} exclusive onChange={handleRangoChange} size="small"> |                             <ToggleButton value={7}>Semanal</ToggleButton> | ||||||
|                         <ToggleButton value={7}>Semanal</ToggleButton> |                             <ToggleButton value={30}>Mensual</ToggleButton> | ||||||
|                         <ToggleButton value={30}>Mensual</ToggleButton> |                             <ToggleButton value={365}>Anual</ToggleButton> | ||||||
|                         <ToggleButton value={365}>Anual</ToggleButton> |                         </ToggleButtonGroup> | ||||||
|                     </ToggleButtonGroup> |                     </Box> | ||||||
|  |                     <HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} /> | ||||||
|                 </Box> |                 </Box> | ||||||
|                 <HistoricalChartWidget ticker={indexData.ticker} mercado="EEUU" dias={dias} /> |             </Paper> | ||||||
|             </Box> |         </Box> | ||||||
|         </Paper> |  | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
							
								
								
									
										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 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 | // Importaciones de nuestro proyecto | ||||||
| const toCSV = (headers: string[], data: CotizacionGanado[]) => { | import { useApiData } from '../../hooks/useApiData'; | ||||||
|     const headerRow = headers.join(';'); | import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||||
|     const dataRows = data.map(row =>  | import type { CotizacionGanado } from '../../models/mercadoModels'; | ||||||
|         [ | import { formatInteger, formatFullDateTime } from '../../utils/formatters'; | ||||||
|             row.categoria, | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
|             row.especificaciones, | import { HolidayAlert } from '../common/HolidayAlert'; | ||||||
|             formatCurrency(row.maximo), |  | ||||||
|             formatCurrency(row.minimo), | /** | ||||||
|             formatCurrency(row.mediano), |  * Función para convertir los datos a formato TSV (Tab-Separated Values) | ||||||
|             formatInteger(row.cabezas), |  * con el formato específico solicitado por redacción. | ||||||
|             formatInteger(row.kilosTotales), |  */ | ||||||
|             formatInteger(row.importeTotal) | const toTSV = (data: CotizacionGanado[]) => { | ||||||
|         ].join(';') |     const dataRows = data.map(row => { | ||||||
|     ); |         // Unimos Categoría y Especificaciones en una sola columna para el copiado | ||||||
|     return [headerRow, ...dataRows].join('\n'); |         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 = () => { | 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 = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!data) return; | ||||||
|         const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total"]; |         const tsvData = toTSV(data); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |          | ||||||
|         copyToClipboard(csvData) |         copyToClipboard(tsvData) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |             .then(() => alert('Datos del Mercado Agroganadero copiados al portapapeles!')) | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (loading) return <CircularProgress />; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|     if (error) return <Alert severity="error">{error}</Alert>; |  | ||||||
|     if (!data) return 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 ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             {isHoliday && ( | ||||||
|                 Copiar como CSV |                 <Box sx={{ mb: 2 }}> | ||||||
|             </Button> |                     <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}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Categoría</TableCell> |                             <TableCell>Categoría / Especificaciones</TableCell> | ||||||
|                             <TableCell>Especificaciones</TableCell> |  | ||||||
|                             <TableCell align="right">Máximo</TableCell> |  | ||||||
|                             <TableCell align="right">Mínimo</TableCell> |  | ||||||
|                             <TableCell align="right">Mediano</TableCell> |  | ||||||
|                             <TableCell align="right">Cabezas</TableCell> |                             <TableCell align="right">Cabezas</TableCell> | ||||||
|                             <TableCell align="right">Kg Total</TableCell> |  | ||||||
|                             <TableCell align="right">Importe Total</TableCell> |                             <TableCell align="right">Importe Total</TableCell> | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {data.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.categoria}</TableCell> |                                 <TableCell>{row.categoria} / {row.especificaciones}</TableCell> | ||||||
|                                 <TableCell>{row.especificaciones}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.maximo)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.minimo)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.mediano)}</TableCell> |  | ||||||
|                                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> |                                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> | ||||||
|                                 <TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> |                                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </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 ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
| import { useApiData } from '../../hooks/useApiData'; | import { useApiData } from '../../hooks/useApiData'; | ||||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | 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[]) => { |  * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values). | ||||||
|     const headerRow = headers.join(';'); |  */ | ||||||
|     const dataRows = data.map(row =>  | const toTSV = (data: CotizacionBolsa[]) => { | ||||||
|         [ |     const dataRows = data.map(row => { | ||||||
|             row.ticker, |         // Formateamos el nombre para que quede como "GGAL.BA (GRUPO FINANCIERO GALICIA)" | ||||||
|             row.nombreEmpresa, |         const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`; | ||||||
|             formatCurrency(row.precioActual), |         const precio = `$${formatCurrency(row.precioActual)}` | ||||||
|             formatCurrency(row.cierreAnterior), |         const cambio = `${formatCurrency(row.porcentajeCambio)}%` //`${row.porcentajeCambio.toFixed(2)}%` | ||||||
|             `${row.porcentajeCambio.toFixed(2)}%` |  | ||||||
|         ].join(';') |         // Unimos los campos con un carácter de tabulación '\t' | ||||||
|     ); |         return [nombreCompleto, precio, cambio].join('\t'); | ||||||
|     return [headerRow, ...dataRows].join('\n'); |     }); | ||||||
|  |  | ||||||
|  |     // 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 = () => { | export const RawBolsaLocalTable = () => { | ||||||
|     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); |     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 = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!priorityData) return; | ||||||
|         const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %"]; |         const tsvData = toTSV(priorityData); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |         copyToClipboard(tsvData) | ||||||
|         copyToClipboard(csvData) |             .then(() => alert('Datos prioritarios copiados al portapapeles.')) | ||||||
|             .then(() => { |  | ||||||
|                 alert('¡Tabla copiada al portapapeles!'); |  | ||||||
|             }) |  | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
| @@ -40,39 +50,75 @@ export const RawBolsaLocalTable = () => { | |||||||
|  |  | ||||||
|     if (loading) return <CircularProgress />; |     if (loading) return <CircularProgress />; | ||||||
|     if (error) return <Alert severity="error">{error}</Alert>; |     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 ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> | ||||||
|                 Copiar como CSV |                 <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}> | ||||||
|             </Button> |                     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}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Ticker</TableCell> |                             <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|                             <TableCell>Nombre</TableCell> |                             <TableCell align="right">Precio Actual</TableCell> | ||||||
|                             <TableCell align="right">Último Precio</TableCell> |                             <TableCell align="right">% Cambio</TableCell> | ||||||
|                             <TableCell align="right">Cierre Anterior</TableCell> |  | ||||||
|                             <TableCell align="right">Variación %</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {priorityData?.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.ticker}</TableCell> |                                 <TableCell> | ||||||
|                                 <TableCell>{row.nombreEmpresa}</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.precioActual)}</TableCell> | ||||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior)}</TableCell> |                                 <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> |  | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|                 </Table> |                 </Table> | ||||||
|             </TableContainer> |             </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> |         </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'; | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
|  |  | ||||||
|  | // Importaciones de nuestro proyecto | ||||||
| import { useApiData } from '../../hooks/useApiData'; | import { useApiData } from '../../hooks/useApiData'; | ||||||
|  | import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | 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[]) => { |  * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values). | ||||||
|     const headerRow = headers.join(';'); |  */ | ||||||
|     const dataRows = data.map(row =>  | const toTSV = (data: CotizacionBolsa[]) => { | ||||||
|         [ |     const dataRows = data.map(row => { | ||||||
|             row.ticker, |         // Formateamos los datos según los requisitos de redacción | ||||||
|             row.nombreEmpresa, |         const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`; | ||||||
|             formatCurrency(row.precioActual, 'USD'), |         const precio = `$${formatCurrency(row.precioActual)}`; | ||||||
|             formatCurrency(row.cierreAnterior, 'USD'), |         const cambio = `${formatCurrency(row.porcentajeCambio)}%`; | ||||||
|             `${row.porcentajeCambio.toFixed(2)}%` |  | ||||||
|         ].join(';') |         return [nombreCompleto, precio, cambio].join('\t'); | ||||||
|     ); |     }); | ||||||
|     return [headerRow, ...dataRows].join('\n'); |     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 = () => { | 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 = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!priorityData) return; | ||||||
|         const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %"]; |         const tsvData = toTSV(priorityData); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |         copyToClipboard(tsvData) | ||||||
|         copyToClipboard(csvData) |             .then(() => alert('Datos prioritarios copiados al portapapeles!')) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |  | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (loading) return <CircularProgress />; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|     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>; |     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 ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             {isHoliday && <Box sx={{ mb: 2 }}><HolidayAlert /></Box>} | ||||||
|                 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}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Ticker</TableCell> |                             <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|                             <TableCell>Nombre</TableCell> |                             <TableCell align="right">Precio Actual</TableCell> | ||||||
|                             <TableCell align="right">Último Precio</TableCell> |                             <TableCell align="right">% Cambio</TableCell> | ||||||
|                             <TableCell align="right">Cierre Anterior</TableCell> |  | ||||||
|                             <TableCell align="right">Variación %</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {priorityData?.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.ticker}</TableCell> |                                 <TableCell> | ||||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> |                                     <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography> | ||||||
|                                 <TableCell align="right">${formatCurrency(row.precioActual, 'USD')}</TableCell> |                                     <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography> | ||||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior, 'USD')}</TableCell> |                                 </TableCell> | ||||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> |                                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |                                 <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|                 </Table> |                 </Table> | ||||||
|             </TableContainer> |             </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> |         </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 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 | // Importaciones de nuestro proyecto | ||||||
| const toCSV = (headers: string[], data: CotizacionGrano[]) => { | import { useApiData } from '../../hooks/useApiData'; | ||||||
|     const headerRow = headers.join(';'); | import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||||
|     const dataRows = data.map(row =>  | import type { CotizacionGrano } from '../../models/mercadoModels'; | ||||||
|         [ | import { formatInteger, formatFullDateTime } from '../../utils/formatters'; | ||||||
|             row.nombre, | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
|             formatInteger(row.precio), | import { HolidayAlert } from '../common/HolidayAlert'; | ||||||
|             formatInteger(row.variacionPrecio), |  | ||||||
|             formatDateOnly(row.fechaOperacion) | /** | ||||||
|         ].join(';') |  * Función para convertir los datos a formato TSV (Tab-Separated Values) | ||||||
|     ); |  * con el formato específico solicitado por redacción. | ||||||
|     return [headerRow, ...dataRows].join('\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 = () => { | 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 = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!data) return; | ||||||
|         const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op."]; |         const tsvData = toTSV(data); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |          | ||||||
|         copyToClipboard(csvData) |         copyToClipboard(tsvData) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |             .then(() => alert('Datos de Granos copiados al portapapeles!')) | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (loading) return <CircularProgress />; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|     if (error) return <Alert severity="error">{error}</Alert>; |  | ||||||
|     if (!data) return 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 ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             {isHoliday && ( | ||||||
|                 Copiar como CSV |                 <Box sx={{ mb: 2 }}> | ||||||
|             </Button> |                     <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}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
| @@ -51,8 +85,6 @@ export const RawGranosTable = () => { | |||||||
|                             <TableCell>Grano</TableCell> |                             <TableCell>Grano</TableCell> | ||||||
|                             <TableCell align="right">Precio ($/Tn)</TableCell> |                             <TableCell align="right">Precio ($/Tn)</TableCell> | ||||||
|                             <TableCell align="right">Variación</TableCell> |                             <TableCell align="right">Variación</TableCell> | ||||||
|                             <TableCell>Fecha Op.</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
| @@ -60,9 +92,7 @@ export const RawGranosTable = () => { | |||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.nombre}</TableCell> |                                 <TableCell>{row.nombre}</TableCell> | ||||||
|                                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> |                                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> | ||||||
|                                 <TableCell align="right">{formatInteger(row.variacionPrecio)}</TableCell> |                                 <TableCell align="right">{row.variacionPrecio === 0 ? '= 0' : formatInteger(row.variacionPrecio)}</TableCell> | ||||||
|                                 <TableCell>{formatDateOnly(row.fechaOperacion)}</TableCell> |  | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </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 apiClient from '../api/apiClient'; | ||||||
| import { AxiosError } from 'axios'; | import { AxiosError } from 'axios'; | ||||||
|  |  | ||||||
| // T es el tipo de dato que esperamos de la API (ej. CotizacionBolsa[]) | // Definimos la URL de la API en un solo lugar y de forma explícita. | ||||||
| export function useApiData<T>(url: string) { | const API_ROOT = 'https://widgets.eldia.com/api'; | ||||||
|  |  | ||||||
|  | export function useApiData<T>(endpoint: string) { | ||||||
|   const [data, setData] = useState<T | null>(null); |   const [data, setData] = useState<T | null>(null); | ||||||
|   const [loading, setLoading] = useState<boolean>(true); |   const [loading, setLoading] = useState<boolean>(true); | ||||||
|   const [error, setError] = useState<string | null>(null); |   const [error, setError] = useState<string | null>(null); | ||||||
| @@ -12,11 +14,13 @@ export function useApiData<T>(url: string) { | |||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(null); | ||||||
|     try { |     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); |       setData(response.data); | ||||||
|     } catch (err) { |     } catch (err) { | ||||||
|       if (err instanceof AxiosError) { |       if (err instanceof AxiosError) { | ||||||
|         setError(`Error al cargar datos: ${err.message}`); |         setError(`Error de red o de la API: ${err.message}`); | ||||||
|       } else { |       } else { | ||||||
|         setError('Ocurrió un error inesperado.'); |         setError('Ocurrió un error inesperado.'); | ||||||
|       } |       } | ||||||
| @@ -24,7 +28,7 @@ export function useApiData<T>(url: string) { | |||||||
|     } finally { |     } finally { | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|     } |     } | ||||||
|   }, [url]); |   }, [endpoint]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     fetchData(); |     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-tarjetas': MercadoAgroCardWidget, | ||||||
|   'mercado-agro-tabla': MercadoAgroWidget, |   'mercado-agro-tabla': MercadoAgroWidget, | ||||||
|    |    | ||||||
|   // Página completa como un widget |   // Widget Página datos crudos | ||||||
|   'pagina-datos-crudos': RawDataView, |   'pagina-datos-crudos': RawDataView, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -11,6 +11,15 @@ export const formatCurrency = (num: number, currency = 'ARS') => { | |||||||
|   }).format(num); |   }).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) => { | export const formatInteger = (num: number) => { | ||||||
|   return new Intl.NumberFormat('es-AR').format(num); |   return new Intl.NumberFormat('es-AR').format(num); | ||||||
| }; | }; | ||||||
| @@ -23,7 +32,7 @@ export const formatFullDateTime = (dateString: string) => { | |||||||
|     year: 'numeric', |     year: 'numeric', | ||||||
|     hour: '2-digit', |     hour: '2-digit', | ||||||
|     minute: '2-digit', |     minute: '2-digit', | ||||||
|     hourCycle: 'h23', // <--- LA CLAVE PARA EL FORMATO 24HS |     hourCycle: 'h23', | ||||||
|     timeZone: 'America/Argentina/Buenos_Aires', |     timeZone: 'America/Argentina/Buenos_Aires', | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,33 +1,24 @@ | |||||||
| import { defineConfig } from 'vite' | import { defineConfig } from 'vite' | ||||||
| import react from '@vitejs/plugin-react' | import react from '@vitejs/plugin-react' | ||||||
| import path from 'path'; // Importa el módulo 'path' de Node |  | ||||||
|  |  | ||||||
| // https://vite.dev/config/ | // frontend/vite.config.ts | ||||||
| export default defineConfig({ | export default defineConfig({ | ||||||
|   plugins: [react()], |   plugins: [react()], | ||||||
|   // --- V INICIO DE LA CONFIGURACIÓN DE LIBRERÍA V --- |  | ||||||
|   build: { |   build: { | ||||||
|     lib: { |     outDir: 'dist', | ||||||
|       // La entrada a nuestra librería. Apunta a nuestro main.tsx |     manifest: 'manifest.json', // Esto asegura que se llame 'manifest.json' y esté en la raíz de 'dist' | ||||||
|       entry: path.resolve(__dirname, 'src/main.tsx'), |   }, | ||||||
|       // El nombre de la variable global que se expondrá |   server: { | ||||||
|       name: 'MercadosWidgets', |     proxy: { | ||||||
|       // El nombre del archivo de salida |       // Cualquier petición que empiece con /api... | ||||||
|       fileName: (format) => `mercados-widgets.${format}.js`, |       '/api': { | ||||||
|     }, |         // ...redirígela a nuestro backend de .NET | ||||||
|     // No necesitamos minificar el CSS si es simple, pero es buena práctica |         target: 'http://localhost:5045', | ||||||
|     cssCodeSplit: true, |         // Cambia el origen de la petición para que el backend la acepte | ||||||
|     // Generar un manifest para saber qué archivos se crearon |         changeOrigin: true, | ||||||
|     manifest: true, |         // No necesitamos reescribir la ruta, ya que el backend espera /api/... | ||||||
|     rollupOptions: { |         // rewrite: (path) => path.replace(/^\/api/, '')  | ||||||
|       // Asegúrate de no externalizar React, para que se incluya en el bundle |       }, | ||||||
|       external: [], |  | ||||||
|       output: { |  | ||||||
|         globals: { |  | ||||||
|           react: 'React', |  | ||||||
|           'react-dom': 'ReactDOM' |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| using Mercados.Core.Entities; | using Mercados.Core.Entities; | ||||||
| using Mercados.Infrastructure.Persistence.Repositories; | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
|  | using Mercados.Infrastructure.Services; | ||||||
| using Microsoft.AspNetCore.Mvc; | using Microsoft.AspNetCore.Mvc; | ||||||
|  |  | ||||||
| namespace Mercados.Api.Controllers | namespace Mercados.Api.Controllers | ||||||
| @@ -11,6 +12,7 @@ namespace Mercados.Api.Controllers | |||||||
|         private readonly ICotizacionBolsaRepository _bolsaRepo; |         private readonly ICotizacionBolsaRepository _bolsaRepo; | ||||||
|         private readonly ICotizacionGranoRepository _granoRepo; |         private readonly ICotizacionGranoRepository _granoRepo; | ||||||
|         private readonly ICotizacionGanadoRepository _ganadoRepo; |         private readonly ICotizacionGanadoRepository _ganadoRepo; | ||||||
|  |         private readonly IHolidayService _holidayService; | ||||||
|         private readonly ILogger<MercadosController> _logger; |         private readonly ILogger<MercadosController> _logger; | ||||||
|  |  | ||||||
|         // Inyectamos TODOS los repositorios que necesita el controlador. |         // Inyectamos TODOS los repositorios que necesita el controlador. | ||||||
| @@ -18,11 +20,13 @@ namespace Mercados.Api.Controllers | |||||||
|             ICotizacionBolsaRepository bolsaRepo, |             ICotizacionBolsaRepository bolsaRepo, | ||||||
|             ICotizacionGranoRepository granoRepo, |             ICotizacionGranoRepository granoRepo, | ||||||
|             ICotizacionGanadoRepository ganadoRepo, |             ICotizacionGanadoRepository ganadoRepo, | ||||||
|  |             IHolidayService holidayService, | ||||||
|             ILogger<MercadosController> logger) |             ILogger<MercadosController> logger) | ||||||
|         { |         { | ||||||
|             _bolsaRepo = bolsaRepo; |             _bolsaRepo = bolsaRepo; | ||||||
|             _granoRepo = granoRepo; |             _granoRepo = granoRepo; | ||||||
|             _ganadoRepo = ganadoRepo; |             _ganadoRepo = ganadoRepo; | ||||||
|  |             _holidayService = holidayService; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
| @@ -34,7 +38,7 @@ namespace Mercados.Api.Controllers | |||||||
|         { |         { | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var data = await _ganadoRepo.ObtenerUltimaTandaAsync(); |                 var data = await _ganadoRepo.ObtenerUltimaTandaDisponibleAsync(); | ||||||
|                 return Ok(data); |                 return Ok(data); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
| @@ -147,5 +151,30 @@ namespace Mercados.Api.Controllers | |||||||
|                 return StatusCode(500, "Ocurrió un error interno en el servidor."); |                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         [HttpGet("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; | ||||||
| using Mercados.Infrastructure.Persistence.Repositories; | using Mercados.Infrastructure.Persistence.Repositories; | ||||||
| using Mercados.Api.Utils; | using Mercados.Api.Utils; | ||||||
|  | using Microsoft.AspNetCore.HttpOverrides; | ||||||
|  | using Mercados.Infrastructure.Services; | ||||||
|  |  | ||||||
| var builder = WebApplication.CreateBuilder(args); | var builder = WebApplication.CreateBuilder(args); | ||||||
|  |  | ||||||
| @@ -18,7 +20,8 @@ builder.Services.AddCors(options => | |||||||
|                       { |                       { | ||||||
|                           policy.WithOrigins("http://localhost:5173", |                           policy.WithOrigins("http://localhost:5173", | ||||||
|                                              "http://192.168.10.78:5173", |                                              "http://192.168.10.78:5173", | ||||||
|                                              "https://www.eldia.com") |                                              "https://www.eldia.com", | ||||||
|  |                                              "https://extras.eldia.com") | ||||||
|                                 .AllowAnyHeader() |                                 .AllowAnyHeader() | ||||||
|                                 .AllowAnyMethod(); |                                 .AllowAnyMethod(); | ||||||
|                       }); |                       }); | ||||||
| @@ -30,6 +33,10 @@ builder.Services.AddScoped<ICotizacionGanadoRepository, CotizacionGanadoReposito | |||||||
| builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | builder.Services.AddScoped<ICotizacionGranoRepository, CotizacionGranoRepository>(); | ||||||
| builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | builder.Services.AddScoped<ICotizacionBolsaRepository, CotizacionBolsaRepository>(); | ||||||
| builder.Services.AddScoped<IFuenteDatoRepository, FuenteDatoRepository>(); | 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) | // Configuración de FluentMigrator (perfecto) | ||||||
| builder.Services | builder.Services | ||||||
| @@ -48,12 +55,23 @@ builder.Services.AddControllers() | |||||||
|         options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); |         options.JsonSerializerOptions.Converters.Add(new UtcDateTimeConverter()); | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
| builder.Services.AddControllers(); |  | ||||||
| builder.Services.AddEndpointsApiExplorer(); | builder.Services.AddEndpointsApiExplorer(); | ||||||
| builder.Services.AddSwaggerGen(); | 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(); | 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) | // Ejecución de migraciones (perfecto) | ||||||
| using (var scope = app.Services.CreateScope()) | 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 | namespace Mercados.Database.Migrations | ||||||
| { | { | ||||||
|     [Migration(20240702133000)] |     [Migration(20250702133000)] | ||||||
|     public class AddNameToStocks : Migration |     public class AddNameToStocks : Migration | ||||||
|     { |     { | ||||||
|         public override void Up() |         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; |             _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() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
| @@ -96,7 +109,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                     { |                     { | ||||||
|                         cotizaciones.Add(new CotizacionGrano |                         cotizaciones.Add(new CotizacionGrano | ||||||
|                         { |                         { | ||||||
|                             Nombre = grain.Key, |                             Nombre = FormatearNombreGrano(grain.Key),  | ||||||
|                             Precio = latestRecord.PrecioCotizacion, |                             Precio = latestRecord.PrecioCotizacion, | ||||||
|                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, |                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, | ||||||
|                             FechaOperacion = latestRecord.FechaOperacionPizarra, |                             FechaOperacion = latestRecord.FechaOperacionPizarra, | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             // Empresas 'Latinas' en Wall Street |             // Empresas 'Latinas' en Wall Street | ||||||
|             "MELI", "GLOB", |             "MELI", "GLOB", | ||||||
|             // ADRs Argentinos |             // 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; |         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 ICotizacionGanadoRepository _cotizacionRepository; | ||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|         private readonly ILogger<MercadoAgroFetcher> _logger; |         private readonly ILogger<MercadoAgroFetcher> _logger; | ||||||
|  |         private readonly TimeZoneInfo _argentinaTimeZone; | ||||||
|  |  | ||||||
|         public MercadoAgroFetcher( |         public MercadoAgroFetcher( | ||||||
|             IHttpClientFactory httpClientFactory, |             IHttpClientFactory httpClientFactory, | ||||||
| @@ -25,40 +26,80 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             _cotizacionRepository = cotizacionRepository; |             _cotizacionRepository = cotizacionRepository; | ||||||
|             _fuenteDatoRepository = fuenteDatoRepository; |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|             _logger = logger; |             _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() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var htmlContent = await GetHtmlContentAsync(); |                 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 (true, "Conexión exitosa, pero no se encontraron nuevos datos de ganado."); | ||||||
|                     return (false, "No se pudo obtener el contenido HTML."); |  | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 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. |                     _logger.LogInformation("El número de registros de {SourceName} ha cambiado. Actualizando...", SourceName); | ||||||
|                     // 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); |                 else | ||||||
|                     return (true, "Conexión exitosa, pero no se encontraron nuevos datos."); |                 { | ||||||
|  |                     // 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(); |                 await UpdateSourceInfoAsync(); | ||||||
|  |  | ||||||
|                 _logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); |                 _logger.LogInformation("Fetch para {SourceName} completado. Se actualizaron {Count} registros.", SourceName, cotizacionesNuevas.Count); | ||||||
|                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); |                 return (true, $"Proceso completado. Se actualizaron {cotizacionesNuevas.Count} registros."); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             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); |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||||
|                 return (false, $"Error: {ex.Message}"); |                 return (false, $"Error: {ex.Message}"); | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ | |||||||
|     <PackageReference Include="Dapper" Version="2.1.66" /> |     <PackageReference Include="Dapper" Version="2.1.66" /> | ||||||
|     <PackageReference Include="MailKit" Version="4.13.0" /> |     <PackageReference Include="MailKit" Version="4.13.0" /> | ||||||
|     <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> |     <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" Version="9.0.6" /> | ||||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> |     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.6" /> | ||||||
|     <PackageReference Include="System.Text.Encoding.CodePages" 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; |             _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) |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
| @@ -30,19 +83,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|  |  | ||||||
|             await connection.ExecuteAsync(sql, cotizaciones); |             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) |         public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias) | ||||||
|         { |         { | ||||||
| @@ -60,10 +100,11 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|                 ORDER BY |                 ORDER BY | ||||||
|                     FechaRegistro ASC;"; |                     FechaRegistro ASC;"; | ||||||
|  |  | ||||||
|             return await connection.QueryAsync<CotizacionGanado>(sql, new {  |             return await connection.QueryAsync<CotizacionGanado>(sql, new | ||||||
|                 Categoria = categoria,  |             { | ||||||
|                 Especificaciones = especificaciones,  |                 Categoria = categoria, | ||||||
|                 Dias = dias  |                 Especificaciones = especificaciones, | ||||||
|  |                 Dias = dias | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -4,8 +4,9 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
| { | { | ||||||
|     public interface ICotizacionGanadoRepository : IBaseRepository |     public interface ICotizacionGanadoRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones); |         Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync(); | ||||||
|         Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync(); |         Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones); | ||||||
|         Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias); |         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 ILogger<DataFetchingService> _logger; | ||||||
|         private readonly IServiceProvider _serviceProvider; |         private readonly IServiceProvider _serviceProvider; | ||||||
|         private readonly TimeZoneInfo _argentinaTimeZone; |         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 _agroSchedule; | ||||||
|         private readonly CronExpression _bcrSchedule; |         private readonly CronExpression _bcrSchedule; | ||||||
|         private readonly CronExpression _bolsasSchedule; |         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? _nextAgroRun; | ||||||
|         private DateTime? _nextBcrRun; |         private DateTime? _nextBcrRun; | ||||||
|         private DateTime? _nextBolsasRun; |         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(); |         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); |         private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); | ||||||
|  |  | ||||||
|  |         // Eliminamos IHolidayService del constructor | ||||||
|         public DataFetchingService( |         public DataFetchingService( | ||||||
|             ILogger<DataFetchingService> logger, |             ILogger<DataFetchingService> logger, | ||||||
|             IServiceProvider serviceProvider, |             IServiceProvider serviceProvider, | ||||||
| @@ -55,6 +56,7 @@ namespace Mercados.Worker | |||||||
|             _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); |             _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); | ||||||
|             _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); |             _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); | ||||||
|             _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); |             _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); | ||||||
|  |             _holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         /// <summary> | ||||||
| @@ -64,44 +66,86 @@ namespace Mercados.Worker | |||||||
|         { |         { | ||||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); |             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||||
|  |  | ||||||
|             // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. |             // La ejecución inicial sigue comentada | ||||||
|             await RunAllFetchersAsync(stoppingToken); |             // await RunAllFetchersAsync(stoppingToken); | ||||||
|  |  | ||||||
|             // Calculamos las primeras ejecuciones programadas al arrancar. |             // Calculamos las primeras ejecuciones programadas al arrancar. | ||||||
|             var utcNow = DateTime.UtcNow; |             var utcNow = DateTime.UtcNow; | ||||||
|             _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); |             _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||||
|             _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); |             _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||||
|             _nextBolsasRun = _bolsasSchedule.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. |             // Usamos un PeriodicTimer que "despierta" cada 30 segundos. | ||||||
|             // Un intervalo más corto aumenta la precisión del disparo de las tareas. |  | ||||||
|             using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); |             using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); | ||||||
|  |  | ||||||
|             while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) |             while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) | ||||||
|             { |             { | ||||||
|                 utcNow = DateTime.UtcNow; |                 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) |                 if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) | ||||||
|                 { |                 { | ||||||
|                     await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); |                     // Comprueba si NO es feriado en Argentina para ejecutar | ||||||
|                     // Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia. |                     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); |                     _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 // Tarea de Granos BCR (diaria) | ||||||
|                 if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) |                 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); |                     _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  |                 // Tarea de Bolsas (recurrente) | ||||||
|                 if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) |                 if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) | ||||||
|                 { |                 { | ||||||
|                     _logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo..."); |                     _logger.LogInformation("Ventana de ejecución para Bolsas detectada."); | ||||||
|                     await Task.WhenAll( |  | ||||||
|                         RunFetcherByNameAsync("YahooFinance", stoppingToken), |                     var bolsaTasks = new List<Task>(); | ||||||
|                         RunFetcherByNameAsync("Finnhub", stoppingToken) |  | ||||||
|                     ); |                     // 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); |                     _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -179,5 +223,14 @@ namespace Mercados.Worker | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         #endregion |         #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("BcrDataFetcher").AddPolicyHandler(GetRetryPolicy()); | ||||||
|         services.AddHttpClient("FinnhubDataFetcher").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>(); |         services.AddHostedService<DataFetchingService>(); | ||||||
|     }) |     }) | ||||||
|     .Build(); |     .Build(); | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|   "Logging": { |   "Logging": { | ||||||
|     "LogLevel": { |     "LogLevel": { | ||||||
|       "Default": "Information", |       "Default": "Debug", | ||||||
|       "Microsoft.Hosting.Lifetime": "Information" |       "Microsoft.Hosting.Lifetime": "Information" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -10,9 +10,10 @@ | |||||||
|     "DefaultConnection": "" |     "DefaultConnection": "" | ||||||
|   }, |   }, | ||||||
|   "Schedules": { |   "Schedules": { | ||||||
|     "MercadoAgroganadero": "0 11 * * 1-5", |     "MercadoAgroganadero": "0 11,15,18,21 * * 1-5", | ||||||
|     "BCR": "30 11 * * 1-5", |     "BCR": "30 11 * * 1-5", | ||||||
|     "Bolsas": "10 11-17 * * 1-5" |     "Bolsas": "10 11-17 * * 1-5", | ||||||
|  |     "Holidays": "0 2 * * 1" | ||||||
|   }, |   }, | ||||||
|   "ApiKeys": { |   "ApiKeys": { | ||||||
|     "Finnhub": "", |     "Finnhub": "", | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user