Improve the dahboard 4
This commit is contained in:
@@ -13,21 +13,77 @@ import { ApiErrorHandler } from '../utils';
|
||||
* Automatically adds authentication headers to requests
|
||||
*/
|
||||
class AuthInterceptor {
|
||||
static isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp <= currentTime;
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token:', error);
|
||||
return true; // Treat invalid tokens as expired
|
||||
}
|
||||
}
|
||||
|
||||
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const bufferSeconds = bufferMinutes * 60;
|
||||
return payload.exp <= (currentTime + bufferSeconds);
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token for expiration check:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshTokenIfNeeded(): Promise<void> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!token || !refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If token is expiring within 5 minutes, proactively refresh
|
||||
if (this.isTokenExpiringSoon(token, 5)) {
|
||||
try {
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
} else {
|
||||
console.warn('Token refresh failed:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Token refresh error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
// Proactively refresh token if needed
|
||||
await this.refreshTokenIfNeeded();
|
||||
|
||||
let token = localStorage.getItem('auth_token');
|
||||
|
||||
console.log('🔐 AuthInterceptor: Checking auth token...', token ? 'Found' : 'Missing');
|
||||
if (token) {
|
||||
console.log('🔐 AuthInterceptor: Token preview:', token.substring(0, 20) + '...');
|
||||
}
|
||||
|
||||
// For development: If no token exists or token is invalid, set a valid demo token
|
||||
if ((!token || token === 'demo-development-token') && window.location.hostname === 'localhost') {
|
||||
console.log('🔧 AuthInterceptor: Development mode - setting valid demo token');
|
||||
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxN2Q1ZTJjZC1hMjk4LTQyNzEtODZjNi01NmEzZGNiNDE0ZWUiLCJ1c2VyX2lkIjoiMTdkNWUyY2QtYTI5OC00MjcxLTg2YzYtNTZhM2RjYjQxNGVlIiwiZW1haWwiOiJ0ZXN0QGRlbW8uY29tIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc1NTI3MzEyNSwiaWF0IjoxNzU1MjcxMzI1LCJpc3MiOiJiYWtlcnktYXV0aCIsImZ1bGxfbmFtZSI6IkRlbW8gVXNlciIsImlzX3ZlcmlmaWVkIjpmYWxzZSwiaXNfYWN0aXZlIjp0cnVlLCJyb2xlIjoidXNlciJ9.RBfzH9L_NKySYkyLzBLYAApnrCFNK4OsGLLO-eCaTSI';
|
||||
localStorage.setItem('auth_token', token);
|
||||
// Check if token is expired
|
||||
if (this.isTokenExpired(token)) {
|
||||
console.warn('Token expired, removing from storage');
|
||||
localStorage.removeItem('auth_token');
|
||||
token = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
@@ -35,9 +91,8 @@ class AuthInterceptor {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
console.log('🔐 AuthInterceptor: Added Authorization header');
|
||||
} else {
|
||||
console.warn('⚠️ AuthInterceptor: No auth token found in localStorage');
|
||||
console.warn('No valid auth token found - authentication required');
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -48,9 +103,6 @@ class AuthInterceptor {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
// Note: 401 handling is now managed by ErrorRecoveryInterceptor
|
||||
// This allows token refresh to work before redirecting to login
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,8 +231,7 @@ class ErrorRecoveryInterceptor {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(token => {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${token}`;
|
||||
return apiClient.request(originalRequest.url, originalRequest);
|
||||
return this.retryRequestWithNewToken(originalRequest, token);
|
||||
}).catch(err => {
|
||||
throw err;
|
||||
});
|
||||
@@ -196,7 +247,7 @@ class ErrorRecoveryInterceptor {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Attempt to refresh token
|
||||
// Use direct fetch to avoid interceptor recursion
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
@@ -207,22 +258,31 @@ class ErrorRecoveryInterceptor {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newToken = data.access_token;
|
||||
|
||||
if (!newToken) {
|
||||
throw new Error('No access token received');
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', newToken);
|
||||
|
||||
// Update new refresh token if provided
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// Process failed queue
|
||||
this.processQueue(null, newToken);
|
||||
|
||||
// Retry original request
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
|
||||
return apiClient.request(originalRequest.url, originalRequest);
|
||||
// Retry original request with new token
|
||||
return this.retryRequestWithNewToken(originalRequest, newToken);
|
||||
|
||||
} catch (refreshError) {
|
||||
console.warn('Token refresh failed:', refreshError);
|
||||
this.processQueue(refreshError, null);
|
||||
|
||||
// Clear auth data and redirect to login
|
||||
@@ -230,7 +290,8 @@ class ErrorRecoveryInterceptor {
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Only redirect if we're not already on the login page
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
@@ -245,6 +306,55 @@ class ErrorRecoveryInterceptor {
|
||||
});
|
||||
}
|
||||
|
||||
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
|
||||
try {
|
||||
// Use direct fetch instead of apiClient to avoid interceptor recursion
|
||||
const url = originalRequest.url || originalRequest.endpoint;
|
||||
const method = originalRequest.method || 'GET';
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...originalRequest.headers
|
||||
}
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (method !== 'GET' && originalRequest.body) {
|
||||
fetchOptions.body = typeof originalRequest.body === 'string'
|
||||
? originalRequest.body
|
||||
: JSON.stringify(originalRequest.body);
|
||||
}
|
||||
|
||||
// Add query parameters if present
|
||||
let fullUrl = url;
|
||||
if (originalRequest.params) {
|
||||
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
|
||||
Object.entries(originalRequest.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
fullUrl = urlWithParams.toString();
|
||||
}
|
||||
|
||||
// Retry request with refreshed token
|
||||
|
||||
const response = await fetch(fullUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (retryError) {
|
||||
console.warn('Request retry failed:', retryError);
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
private static processQueue(error: any, token: string | null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { externalService } from '../services/external.service';
|
||||
import type { WeatherData, TrafficData, WeatherForecast } from '../services/external.service';
|
||||
import type { WeatherData, TrafficData, WeatherForecast, HourlyForecast } from '../services/external.service';
|
||||
|
||||
export const useExternal = () => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
||||
const [trafficForecast, setTrafficForecast] = useState<TrafficData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -67,6 +68,32 @@ export const useExternal = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Hourly Weather Forecast
|
||||
*/
|
||||
const getHourlyWeatherForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 48
|
||||
): Promise<HourlyForecast[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const forecast = await externalService.getHourlyWeatherForecast(tenantId, lat, lon, hours);
|
||||
setHourlyForecast(forecast);
|
||||
|
||||
return forecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get hourly weather forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Historical Weather Data
|
||||
*/
|
||||
@@ -194,11 +221,13 @@ export const useExternal = () => {
|
||||
weatherData,
|
||||
trafficData,
|
||||
weatherForecast,
|
||||
hourlyForecast,
|
||||
trafficForecast,
|
||||
isLoading,
|
||||
error,
|
||||
getCurrentWeather,
|
||||
getWeatherForecast,
|
||||
getHourlyWeatherForecast,
|
||||
getHistoricalWeather,
|
||||
getCurrentTraffic,
|
||||
getTrafficForecast,
|
||||
|
||||
@@ -40,6 +40,18 @@ export interface WeatherForecast {
|
||||
wind_speed?: number;
|
||||
}
|
||||
|
||||
export interface HourlyForecast {
|
||||
forecast_datetime: string;
|
||||
generated_at: string;
|
||||
temperature: number;
|
||||
precipitation: number;
|
||||
humidity: number;
|
||||
wind_speed: number;
|
||||
description: string;
|
||||
source: string;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export class ExternalService {
|
||||
/**
|
||||
* Get Current Weather Data
|
||||
@@ -104,6 +116,43 @@ export class ExternalService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Hourly Weather Forecast (NEW)
|
||||
*/
|
||||
async getHourlyWeatherForecast(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 48
|
||||
): Promise<HourlyForecast[]> {
|
||||
try {
|
||||
console.log(`🕒 Fetching hourly weather forecast from AEMET API for tenant ${tenantId}`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: hours
|
||||
});
|
||||
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/hourly-forecast`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: hours
|
||||
});
|
||||
|
||||
// Handle response format
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
} else if (response && response.data) {
|
||||
return response.data;
|
||||
} else {
|
||||
console.warn('Unexpected hourly forecast response format:', response);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hourly forecast from AEMET API:', error);
|
||||
throw new Error(`Hourly forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Historical Weather Data
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { useExternal } from '../../api';
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
import type { WeatherData, TrafficData, WeatherForecast } from '../../api/services/external.service';
|
||||
import type { HourlyForecast } from '../../types/weather';
|
||||
import { WeatherRecommendationService, WeatherRecommendation, WeatherAnalyzer } from '../../services/weatherRecommendationService';
|
||||
import Card from '../ui/Card';
|
||||
|
||||
interface WeatherContextProps {
|
||||
@@ -28,6 +30,7 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
getCurrentWeather,
|
||||
getCurrentTraffic,
|
||||
getWeatherForecast,
|
||||
getHourlyWeatherForecast,
|
||||
isLoading,
|
||||
error
|
||||
} = useExternal();
|
||||
@@ -35,6 +38,8 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
||||
const [recommendations, setRecommendations] = useState<WeatherRecommendation[]>([]);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
@@ -49,10 +54,11 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
|
||||
try {
|
||||
// Load current weather data from AEMET
|
||||
const [weather, traffic, forecast] = await Promise.allSettled([
|
||||
const [weather, traffic, forecast, hourlyData] = await Promise.allSettled([
|
||||
getCurrentWeather(tenantId, latitude, longitude),
|
||||
getCurrentTraffic(tenantId, latitude, longitude),
|
||||
getWeatherForecast(tenantId, latitude, longitude, 3)
|
||||
getWeatherForecast(tenantId, latitude, longitude, 3),
|
||||
getHourlyWeatherForecast(tenantId, latitude, longitude, 24)
|
||||
]);
|
||||
|
||||
if (weather.status === 'fulfilled') {
|
||||
@@ -76,6 +82,22 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
setWeatherForecast([]);
|
||||
}
|
||||
|
||||
if (hourlyData.status === 'fulfilled') {
|
||||
setHourlyForecast(hourlyData.value);
|
||||
|
||||
// Generate recommendations if we have both current weather and hourly forecast
|
||||
if (weather.status === 'fulfilled' && hourlyData.value.length > 0) {
|
||||
const weatherRecommendations = WeatherRecommendationService.generateRecommendations(
|
||||
weather.value,
|
||||
hourlyData.value
|
||||
);
|
||||
setRecommendations(weatherRecommendations);
|
||||
}
|
||||
} else {
|
||||
console.warn('Hourly forecast unavailable:', hourlyData.reason);
|
||||
setHourlyForecast([]);
|
||||
}
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
console.error('Failed to load weather context:', error);
|
||||
@@ -356,6 +378,105 @@ const WeatherContext: React.FC<WeatherContextProps> = ({ className = '' }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Recommendations - Most Important Section */}
|
||||
{recommendations.length > 0 && (
|
||||
<div className="mb-6 bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 className="font-bold text-blue-800 mb-4 flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
Recomendaciones Meteorológicas para el Negocio
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{recommendations.slice(0, 4).map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className={`p-3 rounded-lg border-l-4 ${
|
||||
rec.priority === 'urgent' ? 'border-red-500 bg-red-50' :
|
||||
rec.priority === 'high' ? 'border-orange-500 bg-orange-50' :
|
||||
rec.priority === 'medium' ? 'border-yellow-500 bg-yellow-50' :
|
||||
'border-green-500 bg-green-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-1">
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-medium ${
|
||||
rec.type === 'production' ? 'bg-blue-100 text-blue-800' :
|
||||
rec.type === 'inventory' ? 'bg-green-100 text-green-800' :
|
||||
rec.type === 'marketing' ? 'bg-purple-100 text-purple-800' :
|
||||
rec.type === 'sales' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{rec.type.toUpperCase()}
|
||||
</span>
|
||||
<span className={`text-xs ml-2 px-1 py-0.5 rounded ${
|
||||
rec.priority === 'urgent' ? 'bg-red-200 text-red-800' :
|
||||
rec.priority === 'high' ? 'bg-orange-200 text-orange-800' :
|
||||
rec.priority === 'medium' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-green-200 text-green-800'
|
||||
}`}>
|
||||
{rec.priority.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="font-semibold text-sm text-gray-900 mb-1">{rec.title}</h5>
|
||||
<p className="text-xs text-gray-700 mb-1">{rec.description}</p>
|
||||
<p className="text-xs font-medium text-gray-800 mb-1">
|
||||
<strong>Acción:</strong> {rec.action}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">{rec.impact}</p>
|
||||
{rec.businessMetrics?.expectedSalesChange && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className={`font-medium ${
|
||||
rec.businessMetrics.expectedSalesChange > 0 ? 'text-green-700' : 'text-red-700'
|
||||
}`}>
|
||||
Ventas: {rec.businessMetrics.expectedSalesChange > 0 ? '+' : ''}{rec.businessMetrics.expectedSalesChange}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right ml-3">
|
||||
<div className="text-xs text-gray-500">{rec.timeframe}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Confianza: {rec.confidence}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-blue-600 flex items-center">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Recomendaciones basadas en datos meteorológicos de AEMET en tiempo real
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hourly Forecast Preview */}
|
||||
{hourlyForecast.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-medium text-gray-800 mb-3">Pronóstico por Horas (Próximas 8 horas)</h4>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{hourlyForecast.slice(0, 8).map((hourly, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-2 text-center">
|
||||
<div className="text-xs text-gray-600 mb-1">
|
||||
{new Date(hourly.forecast_datetime).getHours().toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className="flex justify-center mb-1">
|
||||
{getWeatherIcon(hourly.description, hourly.precipitation)}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{hourly.temperature.toFixed(0)}°
|
||||
</div>
|
||||
{hourly.precipitation > 0 && (
|
||||
<div className="text-xs text-blue-600">
|
||||
{hourly.precipitation.toFixed(1)}mm
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather Forecast */}
|
||||
{weatherForecast.length > 0 && (
|
||||
<div>
|
||||
|
||||
449
frontend/src/services/weatherRecommendationService.ts
Normal file
449
frontend/src/services/weatherRecommendationService.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
// Weather-based Business Recommendations Service
|
||||
import { HourlyForecast, WeatherData } from '../types/weather';
|
||||
|
||||
export interface WeatherRecommendation {
|
||||
id: string;
|
||||
type: 'inventory' | 'production' | 'marketing' | 'operations' | 'sales';
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
title: string;
|
||||
description: string;
|
||||
action: string;
|
||||
impact: string;
|
||||
confidence: number; // 0-100
|
||||
timeframe: string;
|
||||
weatherTrigger: {
|
||||
condition: string;
|
||||
value: number;
|
||||
description: string;
|
||||
};
|
||||
businessMetrics?: {
|
||||
expectedSalesChange?: number; // percentage
|
||||
productDemandChange?: { product: string; change: number }[];
|
||||
staffingRecommendation?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class WeatherRecommendationService {
|
||||
|
||||
/**
|
||||
* Generate comprehensive weather-based recommendations for bakery operations
|
||||
*/
|
||||
static generateRecommendations(
|
||||
currentWeather: WeatherData,
|
||||
hourlyForecast: HourlyForecast[],
|
||||
businessHours = { start: 6, end: 20 }
|
||||
): WeatherRecommendation[] {
|
||||
const recommendations: WeatherRecommendation[] = [];
|
||||
|
||||
// Analyze current conditions
|
||||
recommendations.push(...this.analyzeCurrentConditions(currentWeather));
|
||||
|
||||
// Analyze upcoming 8-hour forecast (most relevant for operations)
|
||||
const operationalForecast = hourlyForecast.slice(0, 8);
|
||||
recommendations.push(...this.analyzeOperationalForecast(operationalForecast));
|
||||
|
||||
// Analyze next 24 hours for strategic planning
|
||||
const strategicForecast = hourlyForecast.slice(0, 24);
|
||||
recommendations.push(...this.analyzeStrategicForecast(strategicForecast));
|
||||
|
||||
// Generate specific product recommendations
|
||||
recommendations.push(...this.generateProductRecommendations(currentWeather, hourlyForecast));
|
||||
|
||||
return recommendations
|
||||
.sort((a, b) => this.getPriorityScore(b.priority) - this.getPriorityScore(a.priority))
|
||||
.slice(0, 10); // Return top 10 recommendations
|
||||
}
|
||||
|
||||
private static analyzeCurrentConditions(weather: WeatherData): WeatherRecommendation[] {
|
||||
const recommendations: WeatherRecommendation[] = [];
|
||||
|
||||
// Rain recommendations
|
||||
if (weather.precipitation && weather.precipitation > 1) {
|
||||
recommendations.push({
|
||||
id: 'rain-current',
|
||||
type: 'operations',
|
||||
priority: weather.precipitation > 5 ? 'high' : 'medium',
|
||||
title: 'Lluvia activa - Ajustar operaciones',
|
||||
description: `Lluvia de ${weather.precipitation.toFixed(1)}mm detectada`,
|
||||
action: 'Aumentar productos de refugio y bebidas calientes',
|
||||
impact: 'Reducción estimada del 15-25% en tráfico peatonal',
|
||||
confidence: 85,
|
||||
timeframe: 'Inmediato (próximas 2 horas)',
|
||||
weatherTrigger: {
|
||||
condition: 'precipitation',
|
||||
value: weather.precipitation,
|
||||
description: 'Precipitación activa'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: -20,
|
||||
productDemandChange: [
|
||||
{ product: 'Café', change: 30 },
|
||||
{ product: 'Bollería', change: 15 },
|
||||
{ product: 'Pan tostado', change: 20 }
|
||||
],
|
||||
staffingRecommendation: 'Mantener personal mínimo'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cold weather recommendations
|
||||
if (weather.temperature && weather.temperature < 10) {
|
||||
recommendations.push({
|
||||
id: 'cold-current',
|
||||
type: 'production',
|
||||
priority: weather.temperature < 5 ? 'high' : 'medium',
|
||||
title: 'Frío intenso - Productos de temporada',
|
||||
description: `Temperatura actual: ${weather.temperature.toFixed(1)}°C`,
|
||||
action: 'Priorizar producción de bebidas calientes y bollería',
|
||||
impact: 'Aumento del 25% en demanda de productos calientes',
|
||||
confidence: 90,
|
||||
timeframe: 'Inmediato',
|
||||
weatherTrigger: {
|
||||
condition: 'temperature',
|
||||
value: weather.temperature,
|
||||
description: 'Temperatura baja'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: 5,
|
||||
productDemandChange: [
|
||||
{ product: 'Chocolate caliente', change: 40 },
|
||||
{ product: 'Croissants', change: 25 },
|
||||
{ product: 'Pan recién horneado', change: 30 }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hot weather recommendations
|
||||
if (weather.temperature && weather.temperature > 25) {
|
||||
recommendations.push({
|
||||
id: 'hot-current',
|
||||
type: 'inventory',
|
||||
priority: weather.temperature > 30 ? 'high' : 'medium',
|
||||
title: 'Calor intenso - Productos refrescantes',
|
||||
description: `Temperatura elevada: ${weather.temperature.toFixed(1)}°C`,
|
||||
action: 'Aumentar stock de bebidas frías y productos ligeros',
|
||||
impact: 'Mayor demanda de productos refrescantes',
|
||||
confidence: 88,
|
||||
timeframe: 'Próximas 4 horas',
|
||||
weatherTrigger: {
|
||||
condition: 'temperature',
|
||||
value: weather.temperature,
|
||||
description: 'Temperatura alta'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: 10,
|
||||
productDemandChange: [
|
||||
{ product: 'Bebidas frías', change: 45 },
|
||||
{ product: 'Helados', change: 60 },
|
||||
{ product: 'Ensaladas', change: 30 }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// High humidity recommendations
|
||||
if (weather.humidity && weather.humidity > 85) {
|
||||
recommendations.push({
|
||||
id: 'humidity-current',
|
||||
type: 'operations',
|
||||
priority: 'medium',
|
||||
title: 'Humedad elevada - Cuidado productos',
|
||||
description: `Humedad del ${weather.humidity.toFixed(0)}%`,
|
||||
action: 'Monitorear conservación del pan y bollería',
|
||||
impact: 'Riesgo de deterioro acelerado de productos',
|
||||
confidence: 75,
|
||||
timeframe: 'Próximas 6 horas',
|
||||
weatherTrigger: {
|
||||
condition: 'humidity',
|
||||
value: weather.humidity,
|
||||
description: 'Humedad alta'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private static analyzeOperationalForecast(forecast: HourlyForecast[]): WeatherRecommendation[] {
|
||||
const recommendations: WeatherRecommendation[] = [];
|
||||
|
||||
// Check for incoming rain in next 8 hours
|
||||
const incomingRain = forecast.find(h => h.precipitation > 1);
|
||||
if (incomingRain) {
|
||||
const hoursUntilRain = forecast.indexOf(incomingRain);
|
||||
recommendations.push({
|
||||
id: 'rain-incoming',
|
||||
type: 'inventory',
|
||||
priority: hoursUntilRain <= 2 ? 'high' : 'medium',
|
||||
title: 'Lluvia prevista - Preparar stock',
|
||||
description: `Lluvia de ${incomingRain.precipitation.toFixed(1)}mm en ${hoursUntilRain + 1} horas`,
|
||||
action: 'Preparar productos para consumo en local',
|
||||
impact: 'Cambio en patrones de compra hacia productos de refugio',
|
||||
confidence: 80,
|
||||
timeframe: `En ${hoursUntilRain + 1} horas`,
|
||||
weatherTrigger: {
|
||||
condition: 'precipitation',
|
||||
value: incomingRain.precipitation,
|
||||
description: 'Lluvia prevista'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: -15,
|
||||
productDemandChange: [
|
||||
{ product: 'Café para llevar', change: -30 },
|
||||
{ product: 'Merienda en local', change: 40 }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check for temperature swings
|
||||
const tempChange = this.getTemperatureSwing(forecast);
|
||||
if (Math.abs(tempChange) > 8) {
|
||||
recommendations.push({
|
||||
id: 'temp-swing',
|
||||
type: 'production',
|
||||
priority: 'medium',
|
||||
title: 'Cambio brusco de temperatura',
|
||||
description: `Variación de ${tempChange.toFixed(1)}°C en las próximas 8 horas`,
|
||||
action: 'Ajustar mix de productos calientes/fríos',
|
||||
impact: 'Necesidad de flexibilidad en la oferta',
|
||||
confidence: 70,
|
||||
timeframe: 'Próximas 8 horas',
|
||||
weatherTrigger: {
|
||||
condition: 'temperature_change',
|
||||
value: tempChange,
|
||||
description: 'Cambio térmico significativo'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private static analyzeStrategicForecast(forecast: HourlyForecast[]): WeatherRecommendation[] {
|
||||
const recommendations: WeatherRecommendation[] = [];
|
||||
|
||||
// Check for extended rain periods
|
||||
const rainyHours = forecast.filter(h => h.precipitation > 0.5).length;
|
||||
if (rainyHours > 12) {
|
||||
recommendations.push({
|
||||
id: 'extended-rain',
|
||||
type: 'marketing',
|
||||
priority: 'high',
|
||||
title: 'Día lluvioso - Estrategia especial',
|
||||
description: `${rainyHours} horas de lluvia previstas en las próximas 24h`,
|
||||
action: 'Activar promociones para consumo en local y delivery',
|
||||
impact: 'Oportunidad de fidelizar clientes con ofertas especiales',
|
||||
confidence: 85,
|
||||
timeframe: 'Próximas 24 horas',
|
||||
weatherTrigger: {
|
||||
condition: 'extended_rain',
|
||||
value: rainyHours,
|
||||
description: 'Periodo lluvioso prolongado'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: -10,
|
||||
staffingRecommendation: 'Reforzar personal de cocina y delivery'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check for perfect weather window
|
||||
const perfectHours = forecast.filter(h =>
|
||||
h.temperature >= 18 && h.temperature <= 24 &&
|
||||
h.precipitation < 0.1
|
||||
).length;
|
||||
|
||||
if (perfectHours > 16) {
|
||||
recommendations.push({
|
||||
id: 'perfect-weather',
|
||||
type: 'marketing',
|
||||
priority: 'high',
|
||||
title: 'Día perfecto - Maximizar ventas',
|
||||
description: `${perfectHours} horas de tiempo ideal en las próximas 24h`,
|
||||
action: 'Preparar productos estrella y aumentar producción',
|
||||
impact: 'Oportunidad de día de ventas excepcionales',
|
||||
confidence: 90,
|
||||
timeframe: 'Próximas 24 horas',
|
||||
weatherTrigger: {
|
||||
condition: 'perfect_weather',
|
||||
value: perfectHours,
|
||||
description: 'Condiciones meteorológicas ideales'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: 25,
|
||||
staffingRecommendation: 'Reforzar todo el personal'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private static generateProductRecommendations(
|
||||
current: WeatherData,
|
||||
forecast: HourlyForecast[]
|
||||
): WeatherRecommendation[] {
|
||||
const recommendations: WeatherRecommendation[] = [];
|
||||
|
||||
// Morning temperature analysis for production planning
|
||||
const morningForecast = forecast.slice(0, 6);
|
||||
const avgMorningTemp = morningForecast.reduce((sum, h) => sum + h.temperature, 0) / morningForecast.length;
|
||||
|
||||
if (avgMorningTemp < 12) {
|
||||
recommendations.push({
|
||||
id: 'morning-cold-products',
|
||||
type: 'production',
|
||||
priority: 'medium',
|
||||
title: 'Mañana fría - Productos de desayuno',
|
||||
description: `Temperatura promedio matutina: ${avgMorningTemp.toFixed(1)}°C`,
|
||||
action: 'Aumentar producción de café, tés y bollería caliente',
|
||||
impact: 'Mayor demanda de productos que generen calor',
|
||||
confidence: 85,
|
||||
timeframe: 'Mañana (6-12h)',
|
||||
weatherTrigger: {
|
||||
condition: 'morning_temperature',
|
||||
value: avgMorningTemp,
|
||||
description: 'Mañana fría'
|
||||
},
|
||||
businessMetrics: {
|
||||
productDemandChange: [
|
||||
{ product: 'Café americano', change: 35 },
|
||||
{ product: 'Té caliente', change: 45 },
|
||||
{ product: 'Croissants recién hechos', change: 25 }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Weekend special weather recommendations
|
||||
const isWeekend = new Date().getDay() === 0 || new Date().getDay() === 6;
|
||||
if (isWeekend && current.temperature > 20 && current.precipitation < 0.1) {
|
||||
recommendations.push({
|
||||
id: 'weekend-special',
|
||||
type: 'sales',
|
||||
priority: 'high',
|
||||
title: 'Fin de semana soleado - Productos especiales',
|
||||
description: 'Condiciones perfectas para aumentar ventas de fin de semana',
|
||||
action: 'Preparar productos de temporada y para llevar',
|
||||
impact: 'Incremento significativo en ventas de fin de semana',
|
||||
confidence: 92,
|
||||
timeframe: 'Hoy',
|
||||
weatherTrigger: {
|
||||
condition: 'weekend_perfect',
|
||||
value: current.temperature,
|
||||
description: 'Fin de semana con buen tiempo'
|
||||
},
|
||||
businessMetrics: {
|
||||
expectedSalesChange: 35,
|
||||
productDemandChange: [
|
||||
{ product: 'Tartas y pasteles', change: 50 },
|
||||
{ product: 'Sándwiches gourmet', change: 40 },
|
||||
{ product: 'Bebidas premium', change: 30 }
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private static getTemperatureSwing(forecast: HourlyForecast[]): number {
|
||||
const temperatures = forecast.map(h => h.temperature);
|
||||
return Math.max(...temperatures) - Math.min(...temperatures);
|
||||
}
|
||||
|
||||
private static getPriorityScore(priority: string): number {
|
||||
switch (priority) {
|
||||
case 'urgent': return 4;
|
||||
case 'high': return 3;
|
||||
case 'medium': return 2;
|
||||
case 'low': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather condition analyzers
|
||||
export class WeatherAnalyzer {
|
||||
|
||||
/**
|
||||
* Analyze if weather conditions are favorable for bakery sales
|
||||
*/
|
||||
static isFavorableForSales(weather: WeatherData): { favorable: boolean; reason: string; confidence: number } {
|
||||
if (weather.precipitation > 2) {
|
||||
return {
|
||||
favorable: false,
|
||||
reason: 'Lluvia moderada a fuerte reduce el tráfico peatonal',
|
||||
confidence: 85
|
||||
};
|
||||
}
|
||||
|
||||
if (weather.temperature < 5) {
|
||||
return {
|
||||
favorable: false,
|
||||
reason: 'Frío extremo disuade las salidas',
|
||||
confidence: 80
|
||||
};
|
||||
}
|
||||
|
||||
if (weather.temperature > 32) {
|
||||
return {
|
||||
favorable: false,
|
||||
reason: 'Calor extremo reduce el apetito por productos de panadería',
|
||||
confidence: 75
|
||||
};
|
||||
}
|
||||
|
||||
if (weather.temperature >= 18 && weather.temperature <= 25 && weather.precipitation < 0.5) {
|
||||
return {
|
||||
favorable: true,
|
||||
reason: 'Condiciones ideales para ventas de panadería',
|
||||
confidence: 90
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
favorable: true,
|
||||
reason: 'Condiciones normales para la operación',
|
||||
confidence: 70
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict customer behavior based on weather
|
||||
*/
|
||||
static predictCustomerBehavior(weather: WeatherData, hourlyForecast: HourlyForecast[]) {
|
||||
const behavior = {
|
||||
peakHours: [] as string[],
|
||||
preferredProducts: [] as string[],
|
||||
shoppingPattern: 'normal' as 'rush' | 'extended' | 'normal' | 'reduced',
|
||||
deliveryDemand: 'normal' as 'high' | 'normal' | 'low'
|
||||
};
|
||||
|
||||
// Rush behavior in bad weather
|
||||
if (weather.precipitation > 1 || weather.temperature < 8) {
|
||||
behavior.shoppingPattern = 'rush';
|
||||
behavior.deliveryDemand = 'high';
|
||||
behavior.peakHours = ['7:00-9:00', '17:00-19:00'];
|
||||
}
|
||||
|
||||
// Extended shopping in perfect weather
|
||||
if (weather.temperature >= 20 && weather.temperature <= 24 && weather.precipitation < 0.1) {
|
||||
behavior.shoppingPattern = 'extended';
|
||||
behavior.peakHours = ['10:00-12:00', '15:00-18:00'];
|
||||
}
|
||||
|
||||
// Product preferences based on temperature
|
||||
if (weather.temperature < 15) {
|
||||
behavior.preferredProducts = ['Café', 'Té', 'Bollería caliente', 'Sopas'];
|
||||
} else if (weather.temperature > 25) {
|
||||
behavior.preferredProducts = ['Bebidas frías', 'Ensaladas', 'Frutas', 'Helados'];
|
||||
} else {
|
||||
behavior.preferredProducts = ['Productos variados', 'Mix equilibrado'];
|
||||
}
|
||||
|
||||
return behavior;
|
||||
}
|
||||
}
|
||||
43
frontend/src/types/weather.ts
Normal file
43
frontend/src/types/weather.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Weather data types for the bakery application
|
||||
|
||||
export interface WeatherData {
|
||||
date: string;
|
||||
temperature: number;
|
||||
precipitation: number;
|
||||
humidity: number;
|
||||
wind_speed: number;
|
||||
pressure: number;
|
||||
description: string;
|
||||
source: 'aemet' | 'synthetic' | 'default';
|
||||
}
|
||||
|
||||
export interface HourlyForecast {
|
||||
forecast_datetime: string;
|
||||
generated_at: string;
|
||||
temperature: number;
|
||||
precipitation: number;
|
||||
humidity: number;
|
||||
wind_speed: number;
|
||||
description: string;
|
||||
source: string;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export interface WeatherForecast {
|
||||
forecast_date: string;
|
||||
generated_at: string;
|
||||
temperature: number;
|
||||
precipitation: number;
|
||||
humidity: number;
|
||||
wind_speed: number;
|
||||
description: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface WeatherImpact {
|
||||
type: 'sales' | 'operations' | 'inventory' | 'staffing';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
description: string;
|
||||
recommendation: string;
|
||||
confidence: number;
|
||||
}
|
||||
Reference in New Issue
Block a user