From afca94dadde6a6291b5d68c2f3bd3d51d4808399 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Mon, 18 Aug 2025 13:36:37 +0200 Subject: [PATCH] Improve the dahboard with the weather info --- frontend/src/api/services/external.service.ts | 49 +- .../components/layout/OperationsLayout.tsx | 14 +- .../components/pos/POSConfigurationForm.tsx | 4 +- .../src/components/simple/WeatherContext.tsx | 417 ++++++++++++++++++ frontend/src/components/simple/index.ts | 7 + frontend/src/hooks/useDashboard.ts | 27 +- frontend/src/hooks/useOrderSuggestions.ts | 406 ++++++++--------- .../src/pages/dashboard/DashboardPage.tsx | 30 +- frontend/src/pages/pos/POSPage.tsx | 45 ++ frontend/src/pages/pos/index.ts | 1 + frontend/src/router/index.tsx | 22 +- services/external/app/external/aemet.py | 27 +- 12 files changed, 747 insertions(+), 302 deletions(-) create mode 100644 frontend/src/components/simple/WeatherContext.tsx create mode 100644 frontend/src/components/simple/index.ts create mode 100644 frontend/src/pages/pos/POSPage.tsx create mode 100644 frontend/src/pages/pos/index.ts diff --git a/frontend/src/api/services/external.service.ts b/frontend/src/api/services/external.service.ts index 96169c9f..790af63e 100644 --- a/frontend/src/api/services/external.service.ts +++ b/frontend/src/api/services/external.service.ts @@ -67,18 +67,8 @@ export class ExternalService { return response; } catch (error) { - console.error('Failed to fetch weather from backend:', error); - - // Fallback weather for Madrid (matching WeatherData schema) - return { - date: new Date().toISOString(), - temperature: 18, - description: 'Parcialmente nublado', - precipitation: 0, - humidity: 65, - wind_speed: 10, - source: 'fallback' - }; + console.error('Failed to fetch weather from AEMET API via backend:', error); + throw new Error(`Weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`); } } @@ -109,8 +99,8 @@ export class ExternalService { return []; } } catch (error) { - console.error('Failed to fetch weather forecast:', error); - return []; + console.error('Failed to fetch weather forecast from AEMET API:', error); + throw new Error(`Weather forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`); } } @@ -136,8 +126,8 @@ export class ExternalService { // Return backend response directly (matches WeatherData interface) return Array.isArray(response) ? response : response.data || []; } catch (error) { - console.error('Failed to fetch historical weather:', error); - return []; + console.error('Failed to fetch historical weather from AEMET API:', error); + throw new Error(`Historical weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`); } } @@ -160,17 +150,8 @@ export class ExternalService { // Return backend response directly (matches TrafficData interface) return response; } catch (error) { - console.error('Failed to fetch traffic data:', error); - - // Fallback traffic data (matching TrafficData schema) - return { - date: new Date().toISOString(), - traffic_volume: 50, - pedestrian_count: 25, - congestion_level: 'medium', - average_speed: 30, - source: 'fallback' - }; + console.error('Failed to fetch traffic data from external API:', error); + throw new Error(`Traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`); } } @@ -194,8 +175,8 @@ export class ExternalService { // Return backend response directly (matches TrafficData interface) return Array.isArray(response) ? response : response.data || []; } catch (error) { - console.error('Failed to fetch traffic forecast:', error); - return []; + console.error('Failed to fetch traffic forecast from external API:', error); + throw new Error(`Traffic forecast unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`); } } @@ -221,8 +202,8 @@ export class ExternalService { // Return backend response directly (matches TrafficData interface) return Array.isArray(response) ? response : response.data || []; } catch (error) { - console.error('Failed to fetch historical traffic:', error); - return []; + console.error('Failed to fetch historical traffic from external API:', error); + throw new Error(`Historical traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`); } } @@ -241,11 +222,12 @@ export class ExternalService { }; try { - // Test weather service + // Test weather service (AEMET API) await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates results.weather = true; } catch (error) { - console.warn('Weather service connectivity test failed:', error); + console.warn('AEMET weather service connectivity test failed:', error); + results.weather = false; } try { @@ -254,6 +236,7 @@ export class ExternalService { results.traffic = true; } catch (error) { console.warn('Traffic service connectivity test failed:', error); + results.traffic = false; } results.overall = results.weather && results.traffic; diff --git a/frontend/src/components/layout/OperationsLayout.tsx b/frontend/src/components/layout/OperationsLayout.tsx index 85345ea3..432569e2 100644 --- a/frontend/src/components/layout/OperationsLayout.tsx +++ b/frontend/src/components/layout/OperationsLayout.tsx @@ -54,8 +54,18 @@ const OperationsLayout: React.FC = () => { icon: 'ShoppingCart', children: [ { id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' }, - { id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }, - { id: 'pos-integration', label: bakeryType === 'individual' ? 'TPV' : 'Multi-TPV', href: '/app/operations/sales/pos-integration' } + { id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' } + ] + }, + { + id: 'pos', + label: bakeryType === 'individual' ? 'TPV' : 'Sistema TPV', + href: '/app/operations/pos', + icon: 'CreditCard', + children: [ + { id: 'integrations', label: 'Integraciones', href: '/app/operations/pos/integrations' }, + { id: 'sync-status', label: 'Estado Sincronización', href: '/app/operations/pos/sync-status' }, + { id: 'transactions', label: 'Transacciones', href: '/app/operations/pos/transactions' } ] } ]; diff --git a/frontend/src/components/pos/POSConfigurationForm.tsx b/frontend/src/components/pos/POSConfigurationForm.tsx index 95c1d1ad..0e9f5320 100644 --- a/frontend/src/components/pos/POSConfigurationForm.tsx +++ b/frontend/src/components/pos/POSConfigurationForm.tsx @@ -6,7 +6,7 @@ import { Globe, Key, Webhook, - Sync, + RefreshCw, AlertTriangle, CheckCircle, Clock, @@ -481,7 +481,7 @@ const POSConfigurationForm: React.FC = ({ {/* Sync Configuration */}

- + Synchronization Settings

diff --git a/frontend/src/components/simple/WeatherContext.tsx b/frontend/src/components/simple/WeatherContext.tsx new file mode 100644 index 00000000..c5d9f071 --- /dev/null +++ b/frontend/src/components/simple/WeatherContext.tsx @@ -0,0 +1,417 @@ +import React, { useEffect, useState } from 'react'; +import { + Cloud, + CloudRain, + Sun, + Thermometer, + Droplets, + Wind, + Gauge, + Users, + TrendingUp, + AlertTriangle, + CheckCircle, + Clock +} from 'lucide-react'; +import { useExternal } from '../../api'; +import { useTenantId } from '../../hooks/useTenantId'; +import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service'; +import Card from '../ui/Card'; + +interface WeatherContextProps { + className?: string; +} + +const WeatherContext: React.FC = ({ className = '' }) => { + const { tenantId } = useTenantId(); + const { + getCurrentWeather, + getCurrentTraffic, + getWeatherForecast, + isLoading, + error + } = useExternal(); + + const [weatherData, setWeatherData] = useState(null); + const [trafficData, setTrafficData] = useState(null); + const [weatherForecast, setWeatherForecast] = useState([]); + const [lastUpdated, setLastUpdated] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Madrid coordinates (default for bakery location) + const latitude = 40.4168; + const longitude = -3.7038; + + const loadWeatherContext = async () => { + if (!tenantId) return; + + setIsRefreshing(true); + + try { + // Load current weather data from AEMET + const [weather, traffic, forecast] = await Promise.allSettled([ + getCurrentWeather(tenantId, latitude, longitude), + getCurrentTraffic(tenantId, latitude, longitude), + getWeatherForecast(tenantId, latitude, longitude, 3) + ]); + + if (weather.status === 'fulfilled') { + setWeatherData(weather.value); + } else { + console.warn('Weather data unavailable:', weather.reason); + setWeatherData(null); + } + + if (traffic.status === 'fulfilled') { + setTrafficData(traffic.value); + } else { + console.warn('Traffic data unavailable:', traffic.reason); + setTrafficData(null); + } + + if (forecast.status === 'fulfilled') { + setWeatherForecast(forecast.value); + } else { + console.warn('Weather forecast unavailable:', forecast.reason); + setWeatherForecast([]); + } + + setLastUpdated(new Date()); + } catch (error) { + console.error('Failed to load weather context:', error); + } finally { + setIsRefreshing(false); + } + }; + + useEffect(() => { + loadWeatherContext(); + + // Refresh data every 15 minutes + const interval = setInterval(loadWeatherContext, 15 * 60 * 1000); + return () => clearInterval(interval); + }, [tenantId]); + + const getWeatherIcon = (description: string = '', precipitation: number = 0) => { + if (precipitation > 0) return ; + if (description.toLowerCase().includes('nublado')) return ; + return ; + }; + + const getTrafficImpact = () => { + if (!trafficData) return null; + + const { traffic_volume = 50, pedestrian_count = 25, congestion_level = 'medium' } = trafficData; + + let impactColor = 'text-green-600'; + let impactText = 'Favorable para las ventas'; + let impactIcon = ; + + if (congestion_level === 'high' || traffic_volume > 80) { + impactColor = 'text-red-600'; + impactText = 'Tráfico alto - posible reducción clientes'; + impactIcon = ; + } else if (congestion_level === 'low' || traffic_volume < 30) { + impactColor = 'text-yellow-600'; + impactText = 'Tráfico bajo - ventas pueden reducirse'; + impactIcon = ; + } + + return { impactColor, impactText, impactIcon, traffic_volume, pedestrian_count }; + }; + + const getWeatherImpact = () => { + if (!weatherData) return null; + + const impacts = []; + + if (weatherData.precipitation && weatherData.precipitation > 0) { + if (weatherData.precipitation > 10) { + impacts.push('☔ Lluvia fuerte: -25% clientes esperados'); + } else { + impacts.push('🌦️ Lluvia ligera: -15% clientes esperados'); + } + } + + if (weatherData.temperature && weatherData.temperature < 5) { + impacts.push('🥶 Frío intenso: +20% bebidas calientes'); + } else if (weatherData.temperature && weatherData.temperature > 25) { + impacts.push('☀️ Tiempo cálido: +15% bebidas frías'); + } + + if (weatherData.wind_speed && weatherData.wind_speed > 30) { + impacts.push('💨 Viento fuerte: posible reducción tránsito'); + } + + return impacts; + }; + + if (isLoading && !weatherData) { + return ( + +
+
+ Cargando datos de AEMET... +
+
+ ); + } + + if (error && !weatherData) { + return ( + +
+ +

Datos Meteorológicos No Disponibles

+

+ No se pueden obtener datos de AEMET en este momento. Los pronósticos pueden verse afectados. +

+ +
+
+ ); + } + + // Warning for synthetic data + if (weatherData && weatherData.source === 'synthetic') { + return ( + +
+ +

⚠️ Usando Datos de Prueba

+

+ El sistema está usando datos meteorológicos sintéticos. Para obtener predicciones precisas, + es necesario configurar la integración con AEMET. +

+
+ Datos actuales (sintéticos):
+ Temperatura: {weatherData.temperature}°C | + Humedad: {weatherData.humidity}% | + Viento: {weatherData.wind_speed} km/h +
+ +
+
+ ); + } + + const trafficImpact = getTrafficImpact(); + const weatherImpacts = getWeatherImpact(); + + return ( + +
+

+ {weatherData && getWeatherIcon(weatherData.description, weatherData.precipitation)} + Clima & Contexto Operacional + + {weatherData?.source === 'aemet' ? 'AEMET Oficial' : weatherData?.source || 'Fuente Externa'} + +

+
+ + {lastUpdated ? `${lastUpdated.toLocaleTimeString()}` : 'No actualizado'} + {isRefreshing && ( +
+ )} +
+
+ + {/* Current Weather Section */} + {weatherData && ( +
+

Condiciones Actuales (AEMET)

+
+
+
+ + Temperatura +
+
+ + {weatherData.temperature?.toFixed(1) || '--'}°C + +
+
+ +
+
+ + Humedad +
+
+ + {weatherData.humidity?.toFixed(0) || '--'}% + +
+
+ +
+
+ + Viento +
+
+ + {weatherData.wind_speed?.toFixed(0) || '--'} km/h + +
+
+ +
+
+ + Presión +
+
+ + {weatherData.pressure?.toFixed(0) || '--'} hPa + +
+
+
+ + {weatherData.description && ( +
+ Descripción: {weatherData.description} +
+ )} + + {weatherData.precipitation && weatherData.precipitation > 0 && ( +
+ Precipitación: {weatherData.precipitation.toFixed(1)} mm +
+ )} +
+ )} + + {/* Traffic Context Section */} + {trafficData && trafficImpact && ( +
+

Contexto de Tráfico

+
+
+
+ + Volumen +
+
+ + {trafficImpact.traffic_volume}% + +
+
+ +
+
+ + Peatones +
+
+ + {trafficImpact.pedestrian_count} + +
+
+
+ +
+ {trafficImpact.impactIcon} + {trafficImpact.impactText} +
+
+ )} + + {/* Business Impact Analysis - Most Important Section */} + {weatherImpacts && weatherImpacts.length > 0 && ( +
+

+ + Impacto Operacional Detectado +

+
+ {weatherImpacts.map((impact, index) => ( +
+ {impact.split(' ')[0]} + {impact.substring(impact.indexOf(' ') + 1)} +
+ ))} +
+
+ + Recomendaciones aplicadas automáticamente en pronósticos de demanda +
+
+ )} + + {/* Weather Forecast */} + {weatherForecast.length > 0 && ( +
+

Pronóstico (Próximos Días)

+
+ {weatherForecast.slice(0, 3).map((forecast, index) => ( +
+
+ {getWeatherIcon(forecast.description, forecast.precipitation)} +
+
+ {new Date(forecast.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })} +
+
{forecast.description}
+
+
+
+
+ {forecast.temperature_max}° / {forecast.temperature_min}° +
+ {forecast.precipitation > 0 && ( +
{forecast.precipitation}mm
+ )} +
+
+ ))} +
+
+ )} + + {/* Data Source Info & Controls */} +
+
+
+ + Fuente: {weatherData?.source === 'aemet' ? '🇪🇸 AEMET (Agencia Estatal de Meteorología)' : + weatherData?.source === 'synthetic' ? '⚠️ Datos de Prueba' : + weatherData?.source || 'No disponible'} + + {weatherData?.date && ( + + Fecha datos: {new Date(weatherData.date).toLocaleString('es-ES')} + + )} +
+ +
+
+
+ ); +}; + +export default WeatherContext; \ No newline at end of file diff --git a/frontend/src/components/simple/index.ts b/frontend/src/components/simple/index.ts new file mode 100644 index 00000000..d6b6bde2 --- /dev/null +++ b/frontend/src/components/simple/index.ts @@ -0,0 +1,7 @@ +export { default as CriticalAlerts } from './CriticalAlerts'; +export { default as OrderSuggestions } from './OrderSuggestions'; +export { default as QuickActions } from './QuickActions'; +export { default as QuickOverview } from './QuickOverview'; +export { default as TodayProduction } from './TodayProduction'; +export { default as TodayRevenue } from './TodayRevenue'; +export { default as WeatherContext } from './WeatherContext'; \ No newline at end of file diff --git a/frontend/src/hooks/useDashboard.ts b/frontend/src/hooks/useDashboard.ts index b4bb6b7d..0dcb70ad 100644 --- a/frontend/src/hooks/useDashboard.ts +++ b/frontend/src/hooks/useDashboard.ts @@ -9,9 +9,14 @@ import { useTenantId } from './useTenantId'; interface DashboardData { weather: { - temperature: number; - description: string; - precipitation: number; + temperature?: number; + description?: string; + precipitation?: number; + humidity?: number; + wind_speed?: number; + pressure?: number; + source: string; + date: string; } | null; todayForecasts: Array<{ product: string; @@ -91,18 +96,16 @@ export const useDashboard = () => { try { // 1. Get available products from inventory service const products = await getProductsList(tenantId); - // 2. Get weather data (Madrid coordinates) + // 2. Get complete weather data from AEMET (Madrid coordinates) let weather = null; try { - weather = await getCurrentWeather(tenantId, 40.4168, -3.7038); + const weatherResponse = await getCurrentWeather(tenantId, 40.4168, -3.7038); + // Use the full WeatherData interface instead of limited subset + weather = weatherResponse; } catch (error) { - console.warn('Failed to fetch weather:', error); - // Fallback weather - weather = { - temperature: 18, - description: 'Parcialmente nublado', - precipitation: 0 - }; + console.error('AEMET weather data unavailable for dashboard:', error); + // No fallback - weather remains null if AEMET API fails + weather = null; } // 3. Generate forecasts for each product diff --git a/frontend/src/hooks/useOrderSuggestions.ts b/frontend/src/hooks/useOrderSuggestions.ts index a17618d0..373fd21b 100644 --- a/frontend/src/hooks/useOrderSuggestions.ts +++ b/frontend/src/hooks/useOrderSuggestions.ts @@ -1,6 +1,6 @@ // Real API hook for Order Suggestions using backend data import { useState, useCallback, useEffect } from 'react'; -import { useSales, useExternal, useForecast, useInventoryProducts } from '../api'; +import { useExternal, useForecast, useInventoryProducts } from '../api'; import { useTenantId } from './useTenantId'; import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions'; @@ -44,10 +44,6 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => { const tenantId = providedTenantId !== undefined ? providedTenantId : hookTenantId; console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError }); - const { - getSalesAnalytics, - getDashboardStats - } = useSales(); const { getProductsList } = useInventoryProducts(); @@ -55,159 +51,213 @@ export const useOrderSuggestions = (providedTenantId?: string | null) => { getCurrentWeather } = useExternal(); const { - createSingleForecast, - getQuickForecasts, - getForecastAlerts + createSingleForecast } = useForecast(); - // Generate daily order suggestions based on real forecast data + // Generate daily order suggestions - creates next day forecasts for all products const generateDailyOrderSuggestions = useCallback(async (): Promise => { if (!tenantId) return []; try { console.log('📊 OrderSuggestions: Generating daily suggestions for tenant:', tenantId); - // Get products list from backend + // Get all products from backend const productsList = await getProductsList(tenantId); - const products = productsList.map(p => p.name); - console.log('📋 OrderSuggestions: Products list:', products); + console.log('📋 OrderSuggestions: Products list:', productsList); - // Filter for daily bakery products (case insensitive) - const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena']; - const dailyProducts = products.filter(p => + // Filter for daily products (bakery items that need daily forecasting) + const dailyProductKeywords = ['pan', 'baguette', 'croissant', 'magdalena', 'tarta', 'bollo', 'empanada']; + const dailyProducts = productsList.filter(p => dailyProductKeywords.some(keyword => - p.toLowerCase().includes(keyword.toLowerCase()) + p.name.toLowerCase().includes(keyword.toLowerCase()) ) ); console.log('🥖 OrderSuggestions: Daily products:', dailyProducts); - // Get quick forecasts for these products - const quickForecasts = await getQuickForecasts(tenantId); - console.log('🔮 OrderSuggestions: Quick forecasts:', quickForecasts); - - // Get weather data to determine urgency - const weather = await getCurrentWeather(tenantId, 40.4168, -3.7038); - console.log('🌤️ OrderSuggestions: Weather data:', weather); - + // Create individual forecasts for each daily product for next day const suggestions: DailyOrderItem[] = []; - console.log('🔄 OrderSuggestions: Processing daily products:', dailyProducts); + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + // Get weather data to determine urgency factors + let weather = null; + try { + weather = await getCurrentWeather(tenantId, 40.4168, -3.7038); + console.log('🌤️ OrderSuggestions: Weather data:', weather); + } catch (error) { + console.warn('⚠️ OrderSuggestions: AEMET weather data unavailable, continuing without weather factors:', error); + weather = null; + } for (const product of dailyProducts) { - console.log('🔄 OrderSuggestions: Processing product:', product); - // Find forecast for this product - const forecast = quickForecasts.find(f => - f.product_name === product || f.inventory_product_id === product - ); + console.log('🔄 OrderSuggestions: Creating forecast for product:', product.name); - if (forecast) { - // Calculate suggested quantity based on prediction - const suggestedQuantity = Math.max(forecast.next_day_prediction, 10); - - // Determine urgency based on confidence and trend - let urgency: 'high' | 'medium' | 'low' = 'medium'; - if (forecast.confidence_score > 0.9 && forecast.trend_direction === 'up') { - urgency = 'high'; - } else if (forecast.confidence_score < 0.7) { - urgency = 'low'; - } - - // Generate reason based on forecast data - let reason = `Predicción: ${forecast.next_day_prediction} unidades`; - if (forecast.trend_direction === 'up') { - reason += ' (tendencia al alza)'; - } - if (weather && weather.precipitation > 0) { - reason += ', lluvia prevista'; - urgency = urgency === 'low' ? 'medium' : 'high'; - } - - const orderItem: DailyOrderItem = { - id: `daily-${product.toLowerCase().replace(/\s+/g, '-')}`, - product, - emoji: getProductEmoji(product), - suggestedQuantity: Math.round(suggestedQuantity), - currentQuantity: Math.round(suggestedQuantity * 0.2), // Assume 20% current stock - unit: 'unidades', - urgency, - reason, - confidence: Math.round(forecast.confidence_score * 100), - supplier: SUPPLIERS[product] || 'Proveedor General', - estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 2.5) * 100) / 100, - lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] + try { + // Create single forecast for next day for this specific product + const forecastRequest = { + inventory_product_id: product.inventory_product_id, + forecast_date: tomorrow, + forecast_days: 1, + location: 'Madrid', + include_external_factors: true, + confidence_intervals: true }; + + const forecasts = await createSingleForecast(tenantId, forecastRequest); + + if (forecasts.length > 0) { + const forecast = forecasts[0]; + const suggestedQuantity = Math.max(Math.round(forecast.predicted_demand), 5); + + // Determine urgency based on confidence and external factors + let urgency: 'high' | 'medium' | 'low' = 'medium'; + const confidence = forecast.confidence_level || 0.8; + + if (confidence > 0.9) { + urgency = 'low'; // High confidence = low urgency + } else if (confidence < 0.6) { + urgency = 'high'; // Low confidence = high urgency, need more buffer + } + + // Adjust based on weather + if (weather && weather.precipitation > 0) { + urgency = urgency === 'low' ? 'medium' : 'high'; + } - suggestions.push(orderItem); - console.log('➕ OrderSuggestions: Added daily suggestion:', orderItem); + // Generate reason based on forecast data + let reason = `Predicción: ${suggestedQuantity} unidades (demanda para mañana)`; + if (weather && weather.precipitation > 0) { + reason += ', lluvia prevista'; + } + + const orderItem: DailyOrderItem = { + id: `daily-${product.name.toLowerCase().replace(/\s+/g, '-')}`, + product: product.name, + emoji: getProductEmoji(product.name), + suggestedQuantity, + currentQuantity: Math.round(suggestedQuantity * (0.1 + Math.random() * 0.3)), // Random current stock 10-40% + unit: getProductUnit(product.name), + urgency, + reason, + confidence: Math.round(confidence * 100), + supplier: SUPPLIERS[product.name] || 'Proveedor General', + estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 2.5) * 100) / 100, + lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] + }; + + suggestions.push(orderItem); + console.log('➕ OrderSuggestions: Added daily suggestion:', orderItem); + } + } catch (error) { + console.warn(`⚠️ OrderSuggestions: Failed to create forecast for ${product.name}:`, error); + // Continue with other products even if one fails } } console.log('🎯 OrderSuggestions: Final daily suggestions:', suggestions); - return suggestions; + return suggestions.sort((a, b) => { + // Sort by urgency (high first) then by suggested quantity + const urgencyOrder = { high: 3, medium: 2, low: 1 }; + if (urgencyOrder[a.urgency] !== urgencyOrder[b.urgency]) { + return urgencyOrder[b.urgency] - urgencyOrder[a.urgency]; + } + return b.suggestedQuantity - a.suggestedQuantity; + }); } catch (error) { console.error('❌ OrderSuggestions: Error in generateDailyOrderSuggestions:', error); return []; } - }, [tenantId, getProductsList, getQuickForecasts, getCurrentWeather]); + }, [tenantId, getProductsList, createSingleForecast, getCurrentWeather]); - // Generate weekly order suggestions based on sales analytics + // Generate weekly order suggestions - creates 7-day forecasts for weekly order products const generateWeeklyOrderSuggestions = useCallback(async (): Promise => { if (!tenantId) return []; - console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId); + try { + console.log('📊 OrderSuggestions: Generating weekly suggestions for tenant:', tenantId); - // Get sales analytics for the past month - const endDate = new Date().toISOString(); - const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + // Get all products from backend + const productsList = await getProductsList(tenantId); + console.log('📋 OrderSuggestions: Products list for weekly:', productsList); - const analytics = await getSalesAnalytics(tenantId, startDate, endDate); - console.log('📈 OrderSuggestions: Sales analytics:', analytics); - - // Weekly products (ingredients and supplies) - const weeklyProducts = [ - 'Café en Grano', - 'Leche Entera', - 'Mantequilla', - 'Vasos de Café', - 'Servilletas', - 'Bolsas papel' - ]; + // Filter for weekly products (ingredients, supplies, drinks that need weekly forecasting) + const weeklyProductKeywords = ['café', 'leche', 'mantequilla', 'vaso', 'servilleta', 'bolsa', 'bebida', 'refresco', 'agua']; + const weeklyProducts = productsList.filter(p => + weeklyProductKeywords.some(keyword => + p.name.toLowerCase().includes(keyword.toLowerCase()) + ) + ); + console.log('📦 OrderSuggestions: Weekly products:', weeklyProducts); const suggestions: WeeklyOrderItem[] = []; - + const startDate = new Date().toISOString().split('T')[0]; + for (const product of weeklyProducts) { - // Calculate weekly consumption based on analytics - const weeklyConsumption = calculateWeeklyConsumption(product, analytics); - const currentStock = Math.round(weeklyConsumption * (0.3 + Math.random() * 0.4)); // Random stock between 30-70% of weekly need - const stockDays = Math.max(1, Math.round((currentStock / weeklyConsumption) * 7)); + console.log('🔄 OrderSuggestions: Creating 7-day forecast for product:', product.name); + + try { + // Create single forecast for 7 days for this specific product + const forecastRequest = { + inventory_product_id: product.inventory_product_id, + forecast_date: startDate, + forecast_days: 7, + location: 'Madrid', + include_external_factors: true, + confidence_intervals: true + }; + + const forecasts = await createSingleForecast(tenantId, forecastRequest); + + if (forecasts.length > 0) { + // Calculate total weekly demand from all forecast days + const totalWeeklyDemand = forecasts.reduce((sum, forecast) => sum + forecast.predicted_demand, 0); + const averageConfidence = forecasts.reduce((sum, forecast) => sum + (forecast.confidence_level || 0.8), 0) / forecasts.length; + + // Determine frequency based on product type + const highFrequencyKeywords = ['café', 'leche', 'mantequilla', 'bebida']; + const frequency: 'weekly' | 'biweekly' = + highFrequencyKeywords.some(keyword => product.name.toLowerCase().includes(keyword)) ? 'weekly' : 'biweekly'; + + // Calculate suggested quantity with buffer + const suggestedQuantity = frequency === 'weekly' ? + Math.round(totalWeeklyDemand * 1.15) : // 15% buffer for weekly + Math.round(totalWeeklyDemand * 2.3); // 2 weeks + 15% buffer + + // Simulate current stock and calculate stock days + const dailyConsumption = totalWeeklyDemand / 7; + const currentStock = Math.round(dailyConsumption * (2 + Math.random() * 8)); // 2-10 days worth + const stockDays = Math.max(1, Math.round(currentStock / dailyConsumption)); - // Determine frequency - const frequency: 'weekly' | 'biweekly' = - ['Café en Grano', 'Leche Entera', 'Mantequilla'].includes(product) ? 'weekly' : 'biweekly'; + const orderItem: WeeklyOrderItem = { + id: `weekly-${product.name.toLowerCase().replace(/\s+/g, '-')}`, + product: product.name, + emoji: getProductEmoji(product.name), + suggestedQuantity, + currentStock, + unit: getProductUnit(product.name), + frequency, + nextOrderDate: getNextOrderDate(frequency, stockDays), + supplier: SUPPLIERS[product.name] || 'Proveedor General', + estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product.name] || 1.0) * 100) / 100, + stockDays, + confidence: Math.round(averageConfidence * 100) + }; - const suggestedQuantity = frequency === 'weekly' ? - Math.round(weeklyConsumption * 1.1) : // 10% buffer for weekly - Math.round(weeklyConsumption * 2.2); // 2 weeks + 10% buffer - - const orderItem: WeeklyOrderItem = { - id: `weekly-${product.toLowerCase().replace(/\s+/g, '-')}`, - product, - emoji: getProductEmoji(product), - suggestedQuantity, - currentStock, - unit: getProductUnit(product), - frequency, - nextOrderDate: getNextOrderDate(frequency, stockDays), - supplier: SUPPLIERS[product] || 'Proveedor General', - estimatedCost: Math.round(suggestedQuantity * (PRODUCT_PRICES[product] || 1.0) * 100) / 100, - stockDays, - confidence: stockDays <= 2 ? 95 : stockDays <= 5 ? 85 : 75 - }; - - suggestions.push(orderItem); + suggestions.push(orderItem); + console.log('➕ OrderSuggestions: Added weekly suggestion:', orderItem); + } + } catch (error) { + console.warn(`⚠️ OrderSuggestions: Failed to create weekly forecast for ${product.name}:`, error); + // Continue with other products even if one fails + } } - return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency - }, [tenantId, getSalesAnalytics]); + console.log('🎯 OrderSuggestions: Final weekly suggestions:', suggestions); + return suggestions.sort((a, b) => a.stockDays - b.stockDays); // Sort by urgency (lowest stock days first) + } catch (error) { + console.error('❌ OrderSuggestions: Error in generateWeeklyOrderSuggestions:', error); + return []; + } + }, [tenantId, getProductsList, createSingleForecast]); // Load order suggestions const loadOrderSuggestions = useCallback(async () => { @@ -282,7 +332,13 @@ function getProductEmoji(product: string): string { 'Mantequilla': '🧈', 'Vasos de Café': '🥤', 'Servilletas': '🧻', - 'Bolsas papel': '🛍️' + 'Bolsas papel': '🛍️', + 'Bebida': '🥤', + 'Refresco': '🥤', + 'Agua': '💧', + 'Tarta': '🎂', + 'Bollo': '🥖', + 'Empanada': '🥟' }; return emojiMap[product] || '📦'; } @@ -298,25 +354,17 @@ function getProductUnit(product: string): string { 'Mantequilla': 'kg', 'Vasos de Café': 'unidades', 'Servilletas': 'paquetes', - 'Bolsas papel': 'unidades' + 'Bolsas papel': 'unidades', + 'Bebida': 'litros', + 'Refresco': 'litros', + 'Agua': 'litros', + 'Tarta': 'unidades', + 'Bollo': 'unidades', + 'Empanada': 'unidades' }; return unitMap[product] || 'unidades'; } -function calculateWeeklyConsumption(product: string, analytics: any): number { - // This would ideally come from sales analytics - // For now, use realistic estimates based on product type - const weeklyEstimates: Record = { - 'Café en Grano': 5, // 5kg per week - 'Leche Entera': 25, // 25L per week - 'Mantequilla': 3, // 3kg per week - 'Vasos de Café': 500, // 500 cups per week - 'Servilletas': 10, // 10 packs per week - 'Bolsas papel': 200 // 200 bags per week - }; - - return weeklyEstimates[product] || 10; -} function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): string { const today = new Date(); @@ -333,97 +381,3 @@ function getNextOrderDate(frequency: 'weekly' | 'biweekly', stockDays: number): return nextDate.toISOString().split('T')[0]; } -// Mock data functions for when tenant ID is not available -function getMockDailyOrders(): DailyOrderItem[] { - return [ - { - id: 'daily-pan-molde', - product: 'Pan de Molde', - emoji: '🍞', - suggestedQuantity: 25, - currentQuantity: 5, - unit: 'unidades', - urgency: 'medium' as const, - reason: 'Predicción: 25 unidades (tendencia estable)', - confidence: 85, - supplier: 'Panadería Central Madrid', - estimatedCost: 45.00, - lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] - }, - { - id: 'daily-baguettes', - product: 'Baguettes', - emoji: '🥖', - suggestedQuantity: 20, - currentQuantity: 3, - unit: 'unidades', - urgency: 'high' as const, - reason: 'Predicción: 20 unidades (tendencia al alza)', - confidence: 92, - supplier: 'Panadería Central Madrid', - estimatedCost: 56.00, - lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] - }, - { - id: 'daily-croissants', - product: 'Croissants', - emoji: '🥐', - suggestedQuantity: 15, - currentQuantity: 8, - unit: 'unidades', - urgency: 'low' as const, - reason: 'Predicción: 15 unidades (demanda regular)', - confidence: 78, - supplier: 'Panadería Central Madrid', - estimatedCost: 37.50, - lastOrderDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0] - } - ]; -} - -function getMockWeeklyOrders(): WeeklyOrderItem[] { - return [ - { - id: 'weekly-cafe-grano', - product: 'Café en Grano', - emoji: '☕', - suggestedQuantity: 5, - currentStock: 2, - unit: 'kg', - frequency: 'weekly' as const, - nextOrderDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - supplier: 'Cafés Premium', - estimatedCost: 87.50, - stockDays: 3, - confidence: 95 - }, - { - id: 'weekly-leche-entera', - product: 'Leche Entera', - emoji: '🥛', - suggestedQuantity: 25, - currentStock: 15, - unit: 'litros', - frequency: 'weekly' as const, - nextOrderDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - supplier: 'Lácteos Frescos SA', - estimatedCost: 23.75, - stockDays: 6, - confidence: 88 - }, - { - id: 'weekly-vasos-cafe', - product: 'Vasos de Café', - emoji: '🥤', - suggestedQuantity: 500, - currentStock: 100, - unit: 'unidades', - frequency: 'biweekly' as const, - nextOrderDate: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - supplier: 'Suministros Hostelería', - estimatedCost: 40.00, - stockDays: 2, - confidence: 90 - } - ]; -} \ No newline at end of file diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index acbec11a..61653494 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -10,6 +10,7 @@ import TodayProduction from '../../components/simple/TodayProduction'; import QuickActions from '../../components/simple/QuickActions'; import QuickOverview from '../../components/simple/QuickOverview'; import OrderSuggestions from '../../components/simple/OrderSuggestions'; +import WeatherContext from '../../components/simple/WeatherContext'; interface DashboardPageProps { onNavigateToOrders?: () => void; @@ -133,9 +134,12 @@ const DashboardPage: React.FC = ({ {weather && (
- {weather.precipitation > 0 ? '🌧️' : weather.temperature > 20 ? '☀️' : '⛅'} + {weather.precipitation && weather.precipitation > 0 ? '🌧️' : weather.temperature && weather.temperature > 20 ? '☀️' : '⛅'} - {weather.temperature}°C +
+ {weather.temperature?.toFixed(1) || '--'}°C + AEMET +
)} @@ -217,6 +221,9 @@ const DashboardPage: React.FC = ({ /> )} + {/* Weather & Context - Comprehensive AEMET Data */} + + {/* Production Section - Core Operations */} = ({ onNavigateToReports={onNavigateToReports} /> - {/* Weather Impact Alert - Context Aware */} - {weather && weather.precipitation > 0 && ( -
-
- 🌧️ -
-

Impacto del Clima Detectado

-

- Se esperan precipitaciones ({weather.precipitation}mm). Las predicciones se han ajustado - automáticamente considerando una reducción del 15% en el tráfico. -

-
-
- Producción y pedidos ya optimizados -
-
-
-
- )} {/* Success Message - When Everything is Good */} {realAlerts.length === 0 && ( diff --git a/frontend/src/pages/pos/POSPage.tsx b/frontend/src/pages/pos/POSPage.tsx new file mode 100644 index 00000000..8c01374f --- /dev/null +++ b/frontend/src/pages/pos/POSPage.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { POSManagementPage } from '../../components/pos'; + +interface POSPageProps { + view?: 'integrations' | 'sync-status' | 'transactions'; +} + +const POSPage: React.FC = ({ view = 'integrations' }) => { + // For now, all views route to the main POS management page + // In the future, you can create separate components for different views + + switch (view) { + case 'integrations': + return ( +
+ +
+ ); + + case 'sync-status': + return ( +
+ {/* Future: Create dedicated sync status view */} + +
+ ); + + case 'transactions': + return ( +
+ {/* Future: Create dedicated transactions view */} + +
+ ); + + default: + return ( +
+ +
+ ); + } +}; + +export default POSPage; \ No newline at end of file diff --git a/frontend/src/pages/pos/index.ts b/frontend/src/pages/pos/index.ts new file mode 100644 index 00000000..1a2e504b --- /dev/null +++ b/frontend/src/pages/pos/index.ts @@ -0,0 +1 @@ +export { default as POSPage } from './POSPage'; \ No newline at end of file diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 193347e0..0aa62415 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -20,6 +20,7 @@ import OrdersPage from '../pages/orders/OrdersPage'; import InventoryPage from '../pages/inventory/InventoryPage'; import SalesPage from '../pages/sales/SalesPage'; import RecipesPage from '../pages/recipes/RecipesPage'; +import POSPage from '../pages/pos/POSPage'; // Analytics Hub Pages import AnalyticsLayout from '../components/layout/AnalyticsLayout'; @@ -177,10 +178,27 @@ export const router = createBrowserRouter([ { path: 'customer-orders', element: + } + ] + }, + { + path: 'pos', + children: [ + { + index: true, + element: }, { - path: 'pos-integration', - element: + path: 'integrations', + element: + }, + { + path: 'sync-status', + element: + }, + { + path: 'transactions', + element: } ] }, diff --git a/services/external/app/external/aemet.py b/services/external/app/external/aemet.py index b42280ee..ba15be99 100644 --- a/services/external/app/external/aemet.py +++ b/services/external/app/external/aemet.py @@ -522,20 +522,39 @@ class AEMETClient(BaseAPIClient): async def get_current_weather(self, latitude: float, longitude: float) -> Optional[Dict[str, Any]]: """Get current weather for coordinates""" try: + logger.info("🌤️ Getting current weather from AEMET", + lat=latitude, lon=longitude, api_key_configured=bool(self.api_key)) + + # Check if API key is configured + if not self.api_key: + logger.error("❌ AEMET API key not configured - falling back to synthetic data") + return await self._get_synthetic_current_weather() + station_id = self.location_service.find_nearest_station(latitude, longitude) if not station_id: - logger.warning("No weather station found", lat=latitude, lon=longitude) + logger.warning("❌ No weather station found for coordinates", + lat=latitude, lon=longitude, + madrid_bounds=f"{AEMETConstants.MADRID_BOUNDS.min_lat}-{AEMETConstants.MADRID_BOUNDS.max_lat}") return await self._get_synthetic_current_weather() + logger.info("✅ Found nearest weather station", station_id=station_id) + weather_data = await self._fetch_current_weather_data(station_id) if weather_data: - return self.parser.parse_current_weather(weather_data) + logger.info("✅ Successfully fetched AEMET weather data", station_id=station_id) + parsed_data = self.parser.parse_current_weather(weather_data) + # Ensure the source is set to AEMET for successful API calls + if parsed_data and isinstance(parsed_data, dict): + parsed_data["source"] = WeatherSource.AEMET.value + return parsed_data - logger.info("Falling back to synthetic weather data", reason="invalid_weather_data") + logger.warning("❌ AEMET API returned no data - falling back to synthetic", + station_id=station_id, reason="invalid_weather_data") return await self._get_synthetic_current_weather() except Exception as e: - logger.error("Failed to get current weather", error=str(e)) + logger.error("❌ AEMET API failed - falling back to synthetic", + error=str(e), error_type=type(e).__name__) return await self._get_synthetic_current_weather() async def get_forecast(self, latitude: float, longitude: float, days: int = 7) -> List[Dict[str, Any]]: