Improve the dahboard with the weather info

This commit is contained in:
Urtzi Alfaro
2025-08-18 13:36:37 +02:00
parent 355c0080cc
commit afca94dadd
12 changed files with 747 additions and 302 deletions

View File

@@ -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' }
]
}
];

View File

@@ -6,7 +6,7 @@ import {
Globe,
Key,
Webhook,
Sync,
RefreshCw,
AlertTriangle,
CheckCircle,
Clock,
@@ -481,7 +481,7 @@ const POSConfigurationForm: React.FC<POSConfigurationFormProps> = ({
{/* Sync Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Sync className="h-5 w-5 mr-2" />
<RefreshCw className="h-5 w-5 mr-2" />
Synchronization Settings
</h3>

View File

@@ -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<WeatherContextProps> = ({ className = '' }) => {
const { tenantId } = useTenantId();
const {
getCurrentWeather,
getCurrentTraffic,
getWeatherForecast,
isLoading,
error
} = useExternal();
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
const [lastUpdated, setLastUpdated] = useState<Date | null>(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 <CloudRain className="h-6 w-6 text-blue-500" />;
if (description.toLowerCase().includes('nublado')) return <Cloud className="h-6 w-6 text-gray-500" />;
return <Sun className="h-6 w-6 text-yellow-500" />;
};
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 = <CheckCircle className="h-4 w-4 text-green-500" />;
if (congestion_level === 'high' || traffic_volume > 80) {
impactColor = 'text-red-600';
impactText = 'Tráfico alto - posible reducción clientes';
impactIcon = <AlertTriangle className="h-4 w-4 text-red-500" />;
} else if (congestion_level === 'low' || traffic_volume < 30) {
impactColor = 'text-yellow-600';
impactText = 'Tráfico bajo - ventas pueden reducirse';
impactIcon = <AlertTriangle className="h-4 w-4 text-yellow-500" />;
}
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 (
<Card className={`p-6 ${className}`}>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Cargando datos de AEMET...</span>
</div>
</Card>
);
}
if (error && !weatherData) {
return (
<Card className={`p-6 border-red-200 bg-red-50 ${className}`}>
<div className="text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-red-800 mb-2">Datos Meteorológicos No Disponibles</h3>
<p className="text-red-700 text-sm mb-4">
No se pueden obtener datos de AEMET en este momento. Los pronósticos pueden verse afectados.
</p>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg transition-colors disabled:opacity-50"
>
{isRefreshing ? 'Reintentando...' : 'Reintentar'}
</button>
</div>
</Card>
);
}
// Warning for synthetic data
if (weatherData && weatherData.source === 'synthetic') {
return (
<Card className={`p-6 border-yellow-200 bg-yellow-50 ${className}`}>
<div className="text-center">
<AlertTriangle className="h-12 w-12 text-yellow-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-yellow-800 mb-2"> Usando Datos de Prueba</h3>
<p className="text-yellow-700 text-sm mb-4">
El sistema está usando datos meteorológicos sintéticos. Para obtener predicciones precisas,
es necesario configurar la integración con AEMET.
</p>
<div className="text-xs text-yellow-600 mb-4">
<strong>Datos actuales (sintéticos):</strong><br/>
Temperatura: {weatherData.temperature}°C |
Humedad: {weatherData.humidity}% |
Viento: {weatherData.wind_speed} km/h
</div>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-4 py-2 bg-yellow-100 hover:bg-yellow-200 text-yellow-800 rounded-lg transition-colors disabled:opacity-50"
>
{isRefreshing ? 'Verificando AEMET...' : 'Verificar Conexión AEMET'}
</button>
</div>
</Card>
);
}
const trafficImpact = getTrafficImpact();
const weatherImpacts = getWeatherImpact();
return (
<Card className={`p-6 border-l-4 border-l-blue-500 ${className}`}>
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 flex items-center">
{weatherData && getWeatherIcon(weatherData.description, weatherData.precipitation)}
<span className="ml-3">Clima & Contexto Operacional</span>
<span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
{weatherData?.source === 'aemet' ? 'AEMET Oficial' : weatherData?.source || 'Fuente Externa'}
</span>
</h3>
<div className="flex items-center text-xs text-gray-500">
<Clock className="h-3 w-3 mr-1" />
{lastUpdated ? `${lastUpdated.toLocaleTimeString()}` : 'No actualizado'}
{isRefreshing && (
<div className="ml-2 animate-spin rounded-full h-3 w-3 border-b border-gray-400"></div>
)}
</div>
</div>
{/* Current Weather Section */}
{weatherData && (
<div className="mb-6">
<h4 className="font-medium text-gray-800 mb-3">Condiciones Actuales (AEMET)</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-blue-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Thermometer className="h-4 w-4 text-blue-600" />
<span className="text-xs text-blue-600">Temperatura</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-blue-900">
{weatherData.temperature?.toFixed(1) || '--'}°C
</span>
</div>
</div>
<div className="bg-cyan-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Droplets className="h-4 w-4 text-cyan-600" />
<span className="text-xs text-cyan-600">Humedad</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-cyan-900">
{weatherData.humidity?.toFixed(0) || '--'}%
</span>
</div>
</div>
<div className="bg-green-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Wind className="h-4 w-4 text-green-600" />
<span className="text-xs text-green-600">Viento</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-green-900">
{weatherData.wind_speed?.toFixed(0) || '--'} km/h
</span>
</div>
</div>
<div className="bg-purple-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Gauge className="h-4 w-4 text-purple-600" />
<span className="text-xs text-purple-600">Presión</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-purple-900">
{weatherData.pressure?.toFixed(0) || '--'} hPa
</span>
</div>
</div>
</div>
{weatherData.description && (
<div className="mt-3 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<strong>Descripción:</strong> {weatherData.description}
</div>
)}
{weatherData.precipitation && weatherData.precipitation > 0 && (
<div className="mt-2 text-sm text-blue-600 bg-blue-50 rounded-lg p-3">
<strong>Precipitación:</strong> {weatherData.precipitation.toFixed(1)} mm
</div>
)}
</div>
)}
{/* Traffic Context Section */}
{trafficData && trafficImpact && (
<div className="mb-6">
<h4 className="font-medium text-gray-800 mb-3">Contexto de Tráfico</h4>
<div className="grid grid-cols-2 gap-4 mb-3">
<div className="bg-orange-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<TrendingUp className="h-4 w-4 text-orange-600" />
<span className="text-xs text-orange-600">Volumen</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-orange-900">
{trafficImpact.traffic_volume}%
</span>
</div>
</div>
<div className="bg-indigo-50 rounded-lg p-3">
<div className="flex items-center justify-between">
<Users className="h-4 w-4 text-indigo-600" />
<span className="text-xs text-indigo-600">Peatones</span>
</div>
<div className="mt-1">
<span className="text-lg font-semibold text-indigo-900">
{trafficImpact.pedestrian_count}
</span>
</div>
</div>
</div>
<div className={`flex items-center text-sm ${trafficImpact.impactColor} bg-gray-50 rounded-lg p-3`}>
{trafficImpact.impactIcon}
<span className="ml-2">{trafficImpact.impactText}</span>
</div>
</div>
)}
{/* Business Impact Analysis - Most Important Section */}
{weatherImpacts && weatherImpacts.length > 0 && (
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="font-semibold text-amber-800 mb-3 flex items-center">
<TrendingUp className="h-5 w-5 mr-2" />
Impacto Operacional Detectado
</h4>
<div className="grid grid-cols-1 gap-3">
{weatherImpacts.map((impact, index) => (
<div key={index} className="flex items-center text-sm font-medium text-amber-700 bg-amber-100 rounded-lg p-3">
<span className="text-lg mr-3">{impact.split(' ')[0]}</span>
<span>{impact.substring(impact.indexOf(' ') + 1)}</span>
</div>
))}
</div>
<div className="mt-3 text-xs text-amber-600 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" />
Recomendaciones aplicadas automáticamente en pronósticos de demanda
</div>
</div>
)}
{/* Weather Forecast */}
{weatherForecast.length > 0 && (
<div>
<h4 className="font-medium text-gray-800 mb-3">Pronóstico (Próximos Días)</h4>
<div className="space-y-2">
{weatherForecast.slice(0, 3).map((forecast, index) => (
<div key={index} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<div className="flex items-center">
{getWeatherIcon(forecast.description, forecast.precipitation)}
<div className="ml-3">
<div className="text-sm font-medium text-gray-900">
{new Date(forecast.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
</div>
<div className="text-xs text-gray-600">{forecast.description}</div>
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-gray-900">
{forecast.temperature_max}° / {forecast.temperature_min}°
</div>
{forecast.precipitation > 0 && (
<div className="text-xs text-blue-600">{forecast.precipitation}mm</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Data Source Info & Controls */}
<div className="mt-4 pt-4 border-t border-gray-200 bg-gray-50 rounded-lg p-3">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-4">
<span className="text-gray-600">
<strong>Fuente:</strong> {weatherData?.source === 'aemet' ? '🇪🇸 AEMET (Agencia Estatal de Meteorología)' :
weatherData?.source === 'synthetic' ? '⚠️ Datos de Prueba' :
weatherData?.source || 'No disponible'}
</span>
{weatherData?.date && (
<span className="text-gray-500">
<strong>Fecha datos:</strong> {new Date(weatherData.date).toLocaleString('es-ES')}
</span>
)}
</div>
<button
onClick={loadWeatherContext}
disabled={isRefreshing}
className="px-3 py-1 text-xs bg-blue-100 text-blue-700 hover:bg-blue-200 rounded transition-colors disabled:opacity-50"
>
{isRefreshing ? '⟳ Actualizando...' : '↻ Actualizar'}
</button>
</div>
</div>
</Card>
);
};
export default WeatherContext;

View File

@@ -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';