Improve the dahboard with the weather info
This commit is contained in:
@@ -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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
417
frontend/src/components/simple/WeatherContext.tsx
Normal file
417
frontend/src/components/simple/WeatherContext.tsx
Normal 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;
|
||||
7
frontend/src/components/simple/index.ts
Normal file
7
frontend/src/components/simple/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user