Fix new services implementation 5
This commit is contained in:
@@ -13,6 +13,8 @@ import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import ProductionPage from './pages/production/ProductionPage';
|
||||
import ForecastPage from './pages/forecast/ForecastPage';
|
||||
import OrdersPage from './pages/orders/OrdersPage';
|
||||
import InventoryPage from './pages/inventory/InventoryPage';
|
||||
import SalesPage from './pages/sales/SalesPage';
|
||||
import SettingsPage from './pages/settings/SettingsPage';
|
||||
import Layout from './components/layout/Layout';
|
||||
|
||||
@@ -29,7 +31,7 @@ import './i18n';
|
||||
// Global styles
|
||||
import './styles/globals.css';
|
||||
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'settings';
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'inventory' | 'sales' | 'settings';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -291,6 +293,10 @@ const App: React.FC = () => {
|
||||
return <OrdersPage />;
|
||||
case 'production':
|
||||
return <ProductionPage />;
|
||||
case 'inventory':
|
||||
return <InventoryPage />;
|
||||
case 'sales':
|
||||
return <SalesPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
|
||||
default:
|
||||
@@ -298,6 +304,8 @@ const App: React.FC = () => {
|
||||
onNavigateToOrders={() => navigateTo('orders')}
|
||||
onNavigateToReports={() => navigateTo('reports')}
|
||||
onNavigateToProduction={() => navigateTo('production')}
|
||||
onNavigateToInventory={() => navigateTo('inventory')}
|
||||
onNavigateToSales={() => navigateTo('sales')}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,13 +16,28 @@ class AuthInterceptor {
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
console.log('🔐 AuthInterceptor: Added Authorization header');
|
||||
} else {
|
||||
console.warn('⚠️ AuthInterceptor: No auth token found in localStorage');
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
@@ -11,7 +11,7 @@ export { useTraining } from './useTraining';
|
||||
export { useForecast } from './useForecast';
|
||||
export { useNotification } from './useNotification';
|
||||
export { useOnboarding, useOnboardingStep } from './useOnboarding';
|
||||
export { useInventory, useInventoryDashboard, useInventoryItem } from './useInventory';
|
||||
export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory';
|
||||
export { useRecipes, useProduction } from './useRecipes';
|
||||
|
||||
// Import hooks for combined usage
|
||||
@@ -23,6 +23,7 @@ import { useTraining } from './useTraining';
|
||||
import { useForecast } from './useForecast';
|
||||
import { useNotification } from './useNotification';
|
||||
import { useOnboarding } from './useOnboarding';
|
||||
import { useInventory } from './useInventory';
|
||||
|
||||
// Combined hook for common operations
|
||||
export const useApiHooks = () => {
|
||||
@@ -30,10 +31,11 @@ export const useApiHooks = () => {
|
||||
const tenant = useTenant();
|
||||
const sales = useSales();
|
||||
const external = useExternal();
|
||||
const training = useTraining();
|
||||
const training = useTraining({ disablePolling: true }); // Disable polling by default
|
||||
const forecast = useForecast();
|
||||
const notification = useNotification();
|
||||
const onboarding = useOnboarding();
|
||||
const inventory = useInventory();
|
||||
|
||||
return {
|
||||
auth,
|
||||
@@ -44,5 +46,6 @@ export const useApiHooks = () => {
|
||||
forecast,
|
||||
notification,
|
||||
onboarding,
|
||||
inventory
|
||||
};
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export const useForecast = () => {
|
||||
setError(null);
|
||||
|
||||
const newForecasts = await forecastingService.createSingleForecast(tenantId, request);
|
||||
setForecasts(prev => [...newForecasts, ...prev]);
|
||||
setForecasts(prev => [...newForecasts, ...(prev || [])]);
|
||||
|
||||
return newForecasts;
|
||||
} catch (error) {
|
||||
@@ -52,7 +52,7 @@ export const useForecast = () => {
|
||||
setError(null);
|
||||
|
||||
const batchForecast = await forecastingService.createBatchForecast(tenantId, request);
|
||||
setBatchForecasts(prev => [batchForecast, ...prev]);
|
||||
setBatchForecasts(prev => [batchForecast, ...(prev || [])]);
|
||||
|
||||
return batchForecast;
|
||||
} catch (error) {
|
||||
@@ -90,7 +90,7 @@ export const useForecast = () => {
|
||||
const batchForecast = await forecastingService.getBatchForecastStatus(tenantId, batchId);
|
||||
|
||||
// Update batch forecast in state
|
||||
setBatchForecasts(prev => prev.map(bf =>
|
||||
setBatchForecasts(prev => (prev || []).map(bf =>
|
||||
bf.id === batchId ? batchForecast : bf
|
||||
));
|
||||
|
||||
@@ -147,7 +147,7 @@ export const useForecast = () => {
|
||||
setError(null);
|
||||
|
||||
const acknowledgedAlert = await forecastingService.acknowledgeForecastAlert(tenantId, alertId);
|
||||
setAlerts(prev => prev.map(alert =>
|
||||
setAlerts(prev => (prev || []).map(alert =>
|
||||
alert.id === alertId ? acknowledgedAlert : alert
|
||||
));
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PaginatedResponse,
|
||||
InventoryDashboardData
|
||||
} from '../services/inventory.service';
|
||||
import type { ProductInfo } from '../types';
|
||||
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
|
||||
@@ -117,17 +118,29 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getInventoryItems(tenantId, params);
|
||||
setItems(response.items);
|
||||
console.log('🔄 useInventory: Loaded items:', response.items);
|
||||
setItems(response.items || []); // Ensure it's always an array
|
||||
setPagination({
|
||||
page: response.page,
|
||||
limit: response.limit,
|
||||
total: response.total,
|
||||
totalPages: response.total_pages
|
||||
page: response.page || 1,
|
||||
limit: response.limit || 20,
|
||||
total: response.total || 0,
|
||||
totalPages: response.total_pages || 0
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ useInventory: Error loading items:', err);
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
|
||||
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
setItems([]); // Set empty array on error
|
||||
|
||||
// Show appropriate error message
|
||||
if (err.response?.status === 401) {
|
||||
console.error('❌ useInventory: Authentication failed');
|
||||
} else if (err.response?.status === 403) {
|
||||
toast.error('No tienes permisos para acceder a este inventario');
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -222,6 +235,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
setStockLevels(levelMap);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading stock levels:', err);
|
||||
// Don't show toast error for this as it's not critical for forecast page
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
@@ -273,6 +287,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
setAlerts(alertsData);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading alerts:', err);
|
||||
// Don't show toast error for this as it's not critical for forecast page
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
@@ -301,6 +316,7 @@ export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
setDashboardData(data);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading dashboard:', err);
|
||||
// Don't show toast error for this as it's not critical for forecast page
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
@@ -507,4 +523,61 @@ export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
||||
adjustStock,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
|
||||
// ========== SIMPLE PRODUCTS HOOK FOR FORECASTING ==========
|
||||
|
||||
export const useInventoryProducts = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Get Products List for Forecasting
|
||||
*/
|
||||
const getProductsList = useCallback(async (tenantId: string): Promise<ProductInfo[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const products = await inventoryService.getProductsList(tenantId);
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get products list';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Product by ID
|
||||
*/
|
||||
const getProductById = useCallback(async (tenantId: string, productId: string): Promise<ProductInfo | null> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const product = await inventoryService.getProductById(tenantId, productId);
|
||||
|
||||
return product;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get product';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
getProductsList,
|
||||
getProductById,
|
||||
};
|
||||
};
|
||||
@@ -156,25 +156,6 @@ export const useSales = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Products List
|
||||
*/
|
||||
const getProductsList = useCallback(async (tenantId: string): Promise<string[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const products = await salesService.getProductsList(tenantId);
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get products list';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Sales Analytics
|
||||
@@ -213,7 +194,6 @@ export const useSales = () => {
|
||||
getDashboardStats,
|
||||
getRecentActivity,
|
||||
exportSalesData,
|
||||
getProductsList,
|
||||
getSalesAnalytics,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
trainingService,
|
||||
forecastingService,
|
||||
notificationService,
|
||||
inventoryService,
|
||||
api
|
||||
} from './services';
|
||||
|
||||
@@ -31,6 +32,8 @@ export {
|
||||
useNotification,
|
||||
useApiHooks,
|
||||
useOnboarding,
|
||||
useInventory,
|
||||
useInventoryProducts
|
||||
} from './hooks';
|
||||
|
||||
// Export WebSocket functionality
|
||||
|
||||
@@ -80,6 +80,7 @@ export class HealthService {
|
||||
{ name: 'Sales', endpoint: '/sales/health' },
|
||||
{ name: 'External', endpoint: '/external/health' },
|
||||
{ name: 'Training', endpoint: '/training/health' },
|
||||
{ name: 'Inventory', endpoint: '/inventory/health' },
|
||||
{ name: 'Forecasting', endpoint: '/forecasting/health' },
|
||||
{ name: 'Notification', endpoint: '/notifications/health' },
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { ProductInfo } from '../types';
|
||||
|
||||
// ========== TYPES AND INTERFACES ==========
|
||||
|
||||
@@ -208,7 +209,7 @@ export interface PaginatedResponse<T> {
|
||||
// ========== INVENTORY SERVICE CLASS ==========
|
||||
|
||||
export class InventoryService {
|
||||
private baseEndpoint = '/api/v1';
|
||||
private baseEndpoint = '';
|
||||
|
||||
// ========== INVENTORY ITEMS ==========
|
||||
|
||||
@@ -230,16 +231,70 @@ export class InventoryService {
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/items${query ? `?${query}` : ''}`;
|
||||
const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
console.log('🔍 InventoryService: Fetching inventory items from:', url);
|
||||
|
||||
try {
|
||||
console.log('🔑 InventoryService: Making request with auth token:', localStorage.getItem('auth_token') ? 'Present' : 'Missing');
|
||||
const response = await apiClient.get(url);
|
||||
console.log('📋 InventoryService: Raw response:', response);
|
||||
console.log('📋 InventoryService: Response type:', typeof response);
|
||||
console.log('📋 InventoryService: Response keys:', response ? Object.keys(response) : 'null');
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(response)) {
|
||||
// Direct array response
|
||||
console.log('✅ InventoryService: Array response with', response.length, 'items');
|
||||
return {
|
||||
items: response,
|
||||
total: response.length,
|
||||
page: 1,
|
||||
limit: response.length,
|
||||
total_pages: 1
|
||||
};
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Check if it's already paginated
|
||||
if ('items' in response && Array.isArray(response.items)) {
|
||||
console.log('✅ InventoryService: Paginated response with', response.items.length, 'items');
|
||||
return response;
|
||||
}
|
||||
|
||||
// Handle object with numeric keys (convert to array)
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
const items = Object.values(response);
|
||||
console.log('✅ InventoryService: Numeric keys response with', items.length, 'items');
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
limit: items.length,
|
||||
total_pages: 1
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty object - this seems to be what we're getting
|
||||
if (keys.length === 0) {
|
||||
console.log('📭 InventoryService: Empty object response - backend has no inventory items for this tenant');
|
||||
throw new Error('NO_INVENTORY_ITEMS'); // This will trigger fallback in useInventory
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: unexpected response format
|
||||
console.warn('⚠️ InventoryService: Unexpected response format, keys:', Object.keys(response || {}));
|
||||
throw new Error('UNEXPECTED_RESPONSE_FORMAT');
|
||||
} catch (error) {
|
||||
console.error('❌ InventoryService: Failed to fetch inventory items:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single inventory item by ID
|
||||
*/
|
||||
async getInventoryItem(tenantId: string, itemId: string): Promise<InventoryItem> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
|
||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,7 +304,7 @@ export class InventoryService {
|
||||
tenantId: string,
|
||||
data: CreateInventoryItemRequest
|
||||
): Promise<InventoryItem> {
|
||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items`, data);
|
||||
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,14 +315,14 @@ export class InventoryService {
|
||||
itemId: string,
|
||||
data: UpdateInventoryItemRequest
|
||||
): Promise<InventoryItem> {
|
||||
return apiClient.put(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`, data);
|
||||
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete inventory item (soft delete)
|
||||
*/
|
||||
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
|
||||
return apiClient.delete(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/${itemId}`);
|
||||
return apiClient.delete(`/tenants/${tenantId}/ingredients/${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -277,7 +332,7 @@ export class InventoryService {
|
||||
tenantId: string,
|
||||
updates: { id: string; data: UpdateInventoryItemRequest }[]
|
||||
): Promise<{ success: number; failed: number; errors: string[] }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/items/bulk-update`, {
|
||||
return apiClient.post(`/tenants/${tenantId}/ingredients/bulk-update`, {
|
||||
updates
|
||||
});
|
||||
}
|
||||
@@ -288,14 +343,16 @@ export class InventoryService {
|
||||
* Get current stock level for an item
|
||||
*/
|
||||
async getStockLevel(tenantId: string, itemId: string): Promise<StockLevel> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}`);
|
||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock levels for all items
|
||||
*/
|
||||
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock`);
|
||||
// TODO: Map to correct endpoint when available
|
||||
return [];
|
||||
// return apiClient.get(`/stock/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +364,7 @@ export class InventoryService {
|
||||
adjustment: StockAdjustmentRequest
|
||||
): Promise<StockMovement> {
|
||||
return apiClient.post(
|
||||
`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/${itemId}/adjust`,
|
||||
`/stock/consume`,
|
||||
adjustment
|
||||
);
|
||||
}
|
||||
@@ -353,7 +410,9 @@ export class InventoryService {
|
||||
* Get current stock alerts
|
||||
*/
|
||||
async getStockAlerts(tenantId: string): Promise<StockAlert[]> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/alerts`);
|
||||
// TODO: Map to correct endpoint when available
|
||||
return [];
|
||||
// return apiClient.get(`/tenants/${tenantId}/inventory/alerts`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +437,17 @@ export class InventoryService {
|
||||
* Get inventory dashboard data
|
||||
*/
|
||||
async getDashboardData(tenantId: string): Promise<InventoryDashboardData> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/dashboard`);
|
||||
// TODO: Map to correct endpoint when available
|
||||
return {
|
||||
total_items: 0,
|
||||
low_stock_items: 0,
|
||||
out_of_stock_items: 0,
|
||||
total_value: 0,
|
||||
recent_movements: [],
|
||||
top_products: [],
|
||||
stock_alerts: []
|
||||
};
|
||||
// return apiClient.get(`/tenants/${tenantId}/inventory/dashboard`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,6 +539,87 @@ export class InventoryService {
|
||||
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
|
||||
}
|
||||
|
||||
// ========== PRODUCTS FOR FORECASTING ==========
|
||||
|
||||
/**
|
||||
* Get Products List with IDs for Forecasting
|
||||
*/
|
||||
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
|
||||
params: { limit: 100 }, // Get all products
|
||||
});
|
||||
|
||||
console.log('🔍 Inventory Products API Response:', response);
|
||||
|
||||
let productsArray: any[] = [];
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
productsArray = response;
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Handle different response formats
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
productsArray = Object.values(response);
|
||||
} else {
|
||||
console.warn('⚠️ Response is object but not with numeric keys:', response);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Response is not array or object:', response);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to ProductInfo objects
|
||||
const products: ProductInfo[] = productsArray
|
||||
.map((product: any) => ({
|
||||
inventory_product_id: product.id || product.inventory_product_id,
|
||||
name: product.name || product.product_name || `Product ${product.id || ''}`,
|
||||
category: product.category,
|
||||
// Add additional fields if available from inventory
|
||||
current_stock: product.current_stock,
|
||||
unit: product.unit,
|
||||
cost_per_unit: product.cost_per_unit
|
||||
}))
|
||||
.filter(product => product.inventory_product_id && product.name);
|
||||
|
||||
console.log('📋 Processed inventory products:', products);
|
||||
|
||||
return products;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch inventory products:', error);
|
||||
|
||||
// Return empty array on error - let dashboard handle fallback
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Product by ID
|
||||
*/
|
||||
async getProductById(tenantId: string, productId: string): Promise<ProductInfo | null> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients/${productId}`);
|
||||
|
||||
if (response) {
|
||||
return {
|
||||
inventory_product_id: response.id || response.inventory_product_id,
|
||||
name: response.name || response.product_name,
|
||||
category: response.category,
|
||||
current_stock: response.current_stock,
|
||||
unit: response.unit,
|
||||
cost_per_unit: response.cost_per_unit
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch product by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const inventoryService = new InventoryService();
|
||||
@@ -181,85 +181,6 @@ export class SalesService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Products List from Sales Data
|
||||
*/
|
||||
async getProductsList(tenantId: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/sales/products`);
|
||||
|
||||
console.log('🔍 Products API Response Analysis:');
|
||||
console.log('- Type:', typeof response);
|
||||
console.log('- Is Array:', Array.isArray(response));
|
||||
console.log('- Keys:', Object.keys(response || {}));
|
||||
console.log('- Response:', response);
|
||||
|
||||
let productsArray: any[] = [];
|
||||
|
||||
// ✅ FIX: Handle different response formats
|
||||
if (Array.isArray(response)) {
|
||||
// Standard array response
|
||||
productsArray = response;
|
||||
console.log('✅ Response is already an array');
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Object with numeric keys - convert to array
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
// Object has numeric keys like {0: {...}, 1: {...}}
|
||||
productsArray = Object.values(response);
|
||||
console.log('✅ Converted object with numeric keys to array');
|
||||
} else {
|
||||
console.warn('⚠️ Response is object but not with numeric keys:', response);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Response is not array or object:', response);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('📦 Products array:', productsArray);
|
||||
|
||||
// Extract product names from the array
|
||||
const productNames = productsArray
|
||||
.map((product: any) => {
|
||||
if (typeof product === 'string') {
|
||||
return product;
|
||||
}
|
||||
if (product && typeof product === 'object') {
|
||||
return product.product_name ||
|
||||
product.name ||
|
||||
product.productName ||
|
||||
null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) // Remove null/undefined values
|
||||
.filter((name: string) => name.trim().length > 0); // Remove empty strings
|
||||
|
||||
console.log('📋 Extracted product names:', productNames);
|
||||
|
||||
if (productNames.length === 0) {
|
||||
console.warn('⚠️ No valid product names extracted from response');
|
||||
}
|
||||
|
||||
return productNames;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch products list:', error);
|
||||
|
||||
// Return fallback products for Madrid bakery
|
||||
return [
|
||||
'Croissants',
|
||||
'Pan de molde',
|
||||
'Baguettes',
|
||||
'Café',
|
||||
'Napolitanas',
|
||||
'Pan integral',
|
||||
'Magdalenas',
|
||||
'Churros'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Sales Summary by Period
|
||||
|
||||
@@ -91,10 +91,21 @@ export class TenantService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get User's Tenants
|
||||
* Get User's Tenants - Get tenants where user is owner
|
||||
*/
|
||||
async getUserTenants(): Promise<TenantInfo[]> {
|
||||
return apiClient.get(`/users/me/tenants`);
|
||||
try {
|
||||
// First get current user info to get user ID
|
||||
const currentUser = await apiClient.get(`/users/me`);
|
||||
const userId = currentUser.id;
|
||||
|
||||
// Then get tenants owned by this user
|
||||
return apiClient.get(`${this.baseEndpoint}/user/${userId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to get user tenants:', error);
|
||||
// Return empty array if API call fails
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
|
||||
import { BaseQueryParams } from './common';
|
||||
|
||||
export interface ProductInfo {
|
||||
inventory_product_id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
sales_count?: number;
|
||||
total_quantity?: number;
|
||||
last_sale_date?: string;
|
||||
}
|
||||
|
||||
export interface SalesData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
@@ -26,7 +35,6 @@ export interface SalesData {
|
||||
cost_of_goods?: number;
|
||||
revenue?: number;
|
||||
quantity_sold?: number;
|
||||
inventory_product_id?: string;
|
||||
discount_applied?: number;
|
||||
weather_condition?: string;
|
||||
}
|
||||
|
||||
@@ -97,20 +97,48 @@ export const useWebSocket = (config: WebSocketConfig) => {
|
||||
// Hook for training job updates
|
||||
export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
const [jobUpdates, setJobUpdates] = useState<any[]>([]);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticationError, setIsAuthenticationError] = useState(false);
|
||||
|
||||
// Get tenant ID reliably
|
||||
// Get tenant ID reliably with enhanced error handling
|
||||
const actualTenantId = tenantId || (() => {
|
||||
try {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
const parsed = JSON.parse(userData);
|
||||
return parsed.current_tenant_id || parsed.tenant_id;
|
||||
}
|
||||
// Try multiple sources for tenant ID
|
||||
const sources = [
|
||||
() => localStorage.getItem('current_tenant_id'),
|
||||
() => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
const parsed = JSON.parse(userData);
|
||||
return parsed.current_tenant_id || parsed.tenant_id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
const authData = localStorage.getItem('auth_data');
|
||||
if (authData) {
|
||||
const parsed = JSON.parse(authData);
|
||||
return parsed.tenant_id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
const tenantContext = localStorage.getItem('tenant_context');
|
||||
if (tenantContext) {
|
||||
const parsed = JSON.parse(tenantContext);
|
||||
return parsed.current_tenant_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
];
|
||||
|
||||
const authData = localStorage.getItem('auth_data');
|
||||
if (authData) {
|
||||
const parsed = JSON.parse(authData);
|
||||
return parsed.tenant_id;
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const tenantId = source();
|
||||
if (tenantId) return tenantId;
|
||||
} catch (e) {
|
||||
console.warn('Failed to get tenant ID from source:', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse tenant ID from storage:', e);
|
||||
@@ -123,8 +151,10 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
|
||||
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
||||
reconnect: true,
|
||||
reconnectInterval: 3000,
|
||||
maxReconnectAttempts: 10
|
||||
reconnectInterval: 3000, // Faster reconnection for training
|
||||
maxReconnectAttempts: 20, // More attempts for long training jobs
|
||||
heartbeatInterval: 15000, // Send heartbeat every 15 seconds for training jobs
|
||||
enableLogging: true // Enable logging for debugging
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -137,40 +167,93 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
sendMessage
|
||||
} = useWebSocket(config);
|
||||
|
||||
// Enhanced message handler
|
||||
// Enhanced message handler with error handling
|
||||
const handleWebSocketMessage = useCallback((message: any) => {
|
||||
// Handle different message structures
|
||||
let processedMessage = message;
|
||||
|
||||
// If message has nested data, flatten it for easier processing
|
||||
if (message.data && typeof message.data === 'object') {
|
||||
processedMessage = {
|
||||
...message,
|
||||
// Merge data properties to root level for backward compatibility
|
||||
...message.data
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Clear connection error when receiving messages
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
|
||||
// Handle different message structures
|
||||
let processedMessage = message;
|
||||
|
||||
// If message has nested data, flatten it for easier processing
|
||||
if (message.data && typeof message.data === 'object') {
|
||||
processedMessage = {
|
||||
...message,
|
||||
// Merge data properties to root level for backward compatibility
|
||||
...message.data,
|
||||
// Preserve original structure
|
||||
_originalData: message.data
|
||||
};
|
||||
}
|
||||
|
||||
// Comprehensive message type handling
|
||||
const trainingMessageTypes = [
|
||||
'progress', 'training_progress',
|
||||
'completed', 'training_completed',
|
||||
'failed', 'training_failed',
|
||||
'error', 'training_error',
|
||||
'started', 'training_started',
|
||||
'heartbeat', 'initial_status',
|
||||
'status_update'
|
||||
];
|
||||
|
||||
if (trainingMessageTypes.includes(message.type)) {
|
||||
// Add to updates array with processed message
|
||||
setJobUpdates(prev => {
|
||||
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
|
||||
return newUpdates;
|
||||
});
|
||||
} else {
|
||||
// Still add to updates for debugging purposes
|
||||
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
|
||||
// Handle special message types
|
||||
if (message.type === 'connection_established') {
|
||||
console.log('WebSocket training connection established:', message);
|
||||
setJobUpdates(prev => [{
|
||||
type: 'connection_established',
|
||||
message: 'Connected to training service',
|
||||
timestamp: Date.now()
|
||||
}, ...prev.slice(0, 49)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keepalive messages (don't show to user, just for connection health)
|
||||
if (message.type === 'pong' || message.type === 'heartbeat') {
|
||||
console.debug('Training WebSocket keepalive received:', message.type);
|
||||
return; // Don't add to jobUpdates
|
||||
}
|
||||
|
||||
if (message.type === 'authentication_error' || message.type === 'authorization_error') {
|
||||
console.error('WebSocket auth/authorization error:', message);
|
||||
setIsAuthenticationError(true);
|
||||
setConnectionError(message.message || 'Authentication/authorization failed - please refresh and try again');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'connection_error') {
|
||||
console.error('WebSocket connection error:', message);
|
||||
setConnectionError(message.message || 'Connection error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'connection_timeout') {
|
||||
console.warn('WebSocket connection timeout:', message);
|
||||
// Don't set as error, just log - connection will retry
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'job_not_found') {
|
||||
console.error('Training job not found:', message);
|
||||
setConnectionError('Training job not found. Please restart the training process.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Comprehensive message type handling
|
||||
const trainingMessageTypes = [
|
||||
'progress', 'training_progress',
|
||||
'completed', 'training_completed',
|
||||
'failed', 'training_failed',
|
||||
'error', 'training_error',
|
||||
'started', 'training_started',
|
||||
'heartbeat', 'initial_status',
|
||||
'status_update'
|
||||
];
|
||||
|
||||
if (trainingMessageTypes.includes(message.type)) {
|
||||
// Add to updates array with processed message
|
||||
setJobUpdates(prev => {
|
||||
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
|
||||
return newUpdates;
|
||||
});
|
||||
} else {
|
||||
// Still add to updates for debugging purposes
|
||||
console.log('Received unknown message type:', message.type, message);
|
||||
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error, message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -179,21 +262,108 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
addMessageHandler(handleWebSocketMessage);
|
||||
}, [addMessageHandler, handleWebSocketMessage]);
|
||||
|
||||
// Send periodic ping to keep connection alive
|
||||
// Enhanced dual ping system for training jobs - prevent disconnection during long training
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
const pingInterval = setInterval(() => {
|
||||
sendMessage({
|
||||
type: 'ping',
|
||||
data: undefined
|
||||
// Primary ping system using JSON messages with training info
|
||||
const keepaliveInterval = setInterval(() => {
|
||||
const success = sendMessage({
|
||||
type: 'training_keepalive',
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId,
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
if (!success) {
|
||||
console.warn('Training keepalive failed - connection may be lost');
|
||||
}
|
||||
}, 10000); // Every 10 seconds for training jobs
|
||||
|
||||
// Secondary simple text ping system (more lightweight)
|
||||
const simplePingInterval = setInterval(() => {
|
||||
// Send a simple text ping to keep connection alive
|
||||
const success = sendMessage({
|
||||
type: 'ping',
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
source: 'training_client'
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.warn('Simple training ping failed');
|
||||
}
|
||||
}, 15000); // Every 15 seconds
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval);
|
||||
clearInterval(keepaliveInterval);
|
||||
clearInterval(simplePingInterval);
|
||||
};
|
||||
}
|
||||
}, [isConnected, sendMessage]);
|
||||
}, [isConnected, sendMessage, jobId, actualTenantId]);
|
||||
|
||||
// Define refresh connection function
|
||||
const refreshConnection = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, 1000);
|
||||
}, [connect, disconnect]);
|
||||
|
||||
// Enhanced connection monitoring and auto-recovery for training jobs
|
||||
useEffect(() => {
|
||||
if (actualTenantId && jobId !== 'pending') {
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
// If we should be connected but aren't, try to reconnect
|
||||
if (status === 'disconnected' && !connectionError) {
|
||||
console.log('WebSocket health check: reconnecting disconnected training socket');
|
||||
connect();
|
||||
}
|
||||
|
||||
// More aggressive stale connection detection for training jobs
|
||||
const lastUpdate = jobUpdates.length > 0 ? jobUpdates[0] : null;
|
||||
if (lastUpdate && status === 'connected') {
|
||||
const timeSinceLastMessage = Date.now() - (lastUpdate.timestamp || 0);
|
||||
if (timeSinceLastMessage > 45000) { // 45 seconds without messages during training
|
||||
console.log('WebSocket health check: connection appears stale, refreshing');
|
||||
refreshConnection();
|
||||
}
|
||||
}
|
||||
|
||||
// If connection is in a failed state for too long, force reconnect
|
||||
if (status === 'failed' && !isAuthenticationError) {
|
||||
console.log('WebSocket health check: recovering from failed state');
|
||||
setTimeout(() => connect(), 2000);
|
||||
}
|
||||
}, 12000); // Check every 12 seconds for training jobs
|
||||
|
||||
return () => clearInterval(healthCheckInterval);
|
||||
}
|
||||
}, [actualTenantId, jobId, status, connectionError, connect, refreshConnection, jobUpdates, isAuthenticationError]);
|
||||
|
||||
// Enhanced connection setup - request current status when connecting
|
||||
useEffect(() => {
|
||||
if (isConnected && jobId !== 'pending') {
|
||||
// Wait a moment for connection to stabilize, then request current status
|
||||
const statusRequestTimer = setTimeout(() => {
|
||||
console.log('Requesting current training status after connection');
|
||||
sendMessage({
|
||||
type: 'get_status',
|
||||
data: {
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(statusRequestTimer);
|
||||
}
|
||||
}, [isConnected, jobId, actualTenantId, sendMessage]);
|
||||
|
||||
return {
|
||||
status,
|
||||
@@ -204,13 +374,33 @@ export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
lastMessage,
|
||||
tenantId: actualTenantId,
|
||||
wsUrl: config.url,
|
||||
// Manual refresh function
|
||||
refreshConnection: useCallback(() => {
|
||||
connectionError,
|
||||
isAuthenticationError,
|
||||
// Enhanced refresh function with status request
|
||||
refreshConnection,
|
||||
// Force retry with new authentication
|
||||
retryWithAuth: useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
// Clear any cached auth data that might be stale
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, 1000);
|
||||
}, [connect, disconnect])
|
||||
}, 2000);
|
||||
}, [connect, disconnect]),
|
||||
// Manual status request function
|
||||
requestStatus: useCallback(() => {
|
||||
if (isConnected && jobId !== 'pending') {
|
||||
return sendMessage({
|
||||
type: 'get_status',
|
||||
data: {
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}, [isConnected, jobId, actualTenantId, sendMessage])
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -99,8 +99,17 @@ export class WebSocketManager {
|
||||
this.handlers.onClose?.(event);
|
||||
|
||||
// Auto-reconnect if enabled and not manually closed
|
||||
if (this.config.reconnect && event.code !== 1000) {
|
||||
// Don't reconnect on authorization failures or job not found (1008) with specific reasons
|
||||
const isAuthorizationError = event.code === 1008 &&
|
||||
(event.reason === 'Authentication failed' || event.reason === 'Authorization failed');
|
||||
const isJobNotFound = event.code === 1008 && event.reason === 'Job not found';
|
||||
|
||||
if (this.config.reconnect && event.code !== 1000 && !isAuthorizationError && !isJobNotFound) {
|
||||
this.scheduleReconnect();
|
||||
} else if (isAuthorizationError || isJobNotFound) {
|
||||
this.log('Connection failed - stopping reconnection attempts:', event.reason);
|
||||
this.status = 'failed';
|
||||
this.handlers.onReconnectFailed?.();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Sparkles, CheckCircle, Clock, ArrowRight, Coffee,
|
||||
TrendingUp, Target, Loader, AlertTriangle, Mail,
|
||||
@@ -18,6 +18,11 @@ interface SimplifiedTrainingProgressProps {
|
||||
onTimeout?: () => void;
|
||||
onBackgroundMode?: () => void;
|
||||
onEmailNotification?: (email: string) => void;
|
||||
// Optional WebSocket debugging info
|
||||
websocketStatus?: string;
|
||||
connectionError?: string;
|
||||
isConnected?: boolean;
|
||||
onRetryConnection?: () => void;
|
||||
}
|
||||
|
||||
// Proceso simplificado de entrenamiento en 3 etapas
|
||||
@@ -79,13 +84,18 @@ export default function SimplifiedTrainingProgress({
|
||||
progress,
|
||||
onTimeout,
|
||||
onBackgroundMode,
|
||||
onEmailNotification
|
||||
onEmailNotification,
|
||||
websocketStatus,
|
||||
connectionError,
|
||||
isConnected,
|
||||
onRetryConnection
|
||||
}: SimplifiedTrainingProgressProps) {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTimeoutOptions, setShowTimeoutOptions] = useState(false);
|
||||
const [emailForNotification, setEmailForNotification] = useState('');
|
||||
const [celebratingStage, setCelebratingStage] = useState<string | null>(null);
|
||||
const [startTime] = useState(Date.now());
|
||||
const celebratedStagesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Show timeout options after 7 minutes for better UX
|
||||
useEffect(() => {
|
||||
@@ -98,17 +108,18 @@ export default function SimplifiedTrainingProgress({
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress.status, progress.progress]);
|
||||
|
||||
// Celebrate stage completions
|
||||
// Celebrate stage completions - fixed to prevent infinite re-renders
|
||||
useEffect(() => {
|
||||
TRAINING_STAGES.forEach(stage => {
|
||||
if (progress.progress >= stage.progressRange[1] &&
|
||||
celebratingStage !== stage.id &&
|
||||
!celebratedStagesRef.current.has(stage.id) &&
|
||||
progress.progress > 0) {
|
||||
setCelebratingStage(stage.id);
|
||||
celebratedStagesRef.current.add(stage.id);
|
||||
setTimeout(() => setCelebratingStage(null), 3000);
|
||||
}
|
||||
});
|
||||
}, [progress.progress, celebratingStage]);
|
||||
}, [progress.progress]);
|
||||
|
||||
const getCurrentStage = () => {
|
||||
return TRAINING_STAGES.find(stage =>
|
||||
@@ -258,6 +269,36 @@ export default function SimplifiedTrainingProgress({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Debug Info */}
|
||||
{(websocketStatus || connectionError) && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${
|
||||
connectionError
|
||||
? 'bg-red-50 text-red-800 border border-red-200'
|
||||
: isConnected
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-yellow-50 text-yellow-800 border border-yellow-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<strong>Estado de conexión:</strong>
|
||||
{connectionError
|
||||
? ` Error - ${connectionError}`
|
||||
: isConnected
|
||||
? ' ✅ Conectado a tiempo real'
|
||||
: ' ⏳ Conectando...'}
|
||||
</div>
|
||||
{connectionError && onRetryConnection && (
|
||||
<button
|
||||
onClick={onRetryConnection}
|
||||
className="ml-2 px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional Details */}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
User,
|
||||
Bell,
|
||||
ChevronDown,
|
||||
ChefHat
|
||||
ChefHat,
|
||||
Warehouse,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -42,6 +44,8 @@ const Layout: React.FC<LayoutProps> = ({
|
||||
{ id: 'dashboard', label: 'Inicio', icon: Home, href: '/dashboard' },
|
||||
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
||||
{ id: 'production', label: 'Producción', icon: ChefHat, href: '/production' },
|
||||
{ id: 'inventory', label: 'Inventario', icon: Warehouse, href: '/inventory' },
|
||||
{ id: 'sales', label: 'Ventas', icon: ShoppingCart, href: '/sales' },
|
||||
{ id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' },
|
||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
@@ -245,7 +245,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'blue',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
textColor: 'text-blue-900'
|
||||
textColor: 'text-blue-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
central_baker_satellite: {
|
||||
icon: Truck,
|
||||
@@ -254,7 +255,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'amber',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
textColor: 'text-amber-900'
|
||||
textColor: 'text-amber-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
retail_bakery: {
|
||||
icon: Store,
|
||||
@@ -263,7 +265,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-900'
|
||||
textColor: 'text-green-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
hybrid_bakery: {
|
||||
icon: Settings2,
|
||||
@@ -272,7 +275,28 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-200',
|
||||
textColor: 'text-purple-900'
|
||||
textColor: 'text-purple-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
coffee_shop_individual: {
|
||||
icon: Coffee,
|
||||
title: 'Cafetería Individual',
|
||||
description: 'Servicio de bebidas y comida ligera con preparación in-situ',
|
||||
color: 'amber',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
textColor: 'text-amber-900',
|
||||
businessType: 'coffee_shop'
|
||||
},
|
||||
coffee_shop_chain: {
|
||||
icon: Building2,
|
||||
title: 'Cafetería en Cadena',
|
||||
description: 'Múltiples ubicaciones con productos estandarizados',
|
||||
color: 'indigo',
|
||||
bgColor: 'bg-indigo-50',
|
||||
borderColor: 'border-indigo-200',
|
||||
textColor: 'text-indigo-900',
|
||||
businessType: 'coffee_shop'
|
||||
},
|
||||
// Legacy fallbacks
|
||||
production: {
|
||||
@@ -282,7 +306,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'blue',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
textColor: 'text-blue-900'
|
||||
textColor: 'text-blue-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
retail: {
|
||||
icon: Store,
|
||||
@@ -291,7 +316,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-900'
|
||||
textColor: 'text-green-900',
|
||||
businessType: 'bakery'
|
||||
},
|
||||
hybrid: {
|
||||
icon: Settings2,
|
||||
@@ -300,7 +326,8 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-200',
|
||||
textColor: 'text-purple-900'
|
||||
textColor: 'text-purple-900',
|
||||
businessType: 'bakery'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -331,12 +358,28 @@ const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Coffee className="w-4 h-4 text-brown-500" />
|
||||
<Package className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{analysis.finished_product_count} productos finales
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced business intelligence insights if available */}
|
||||
{config.businessType === 'coffee_shop' && (
|
||||
<div className="mb-4 p-3 bg-amber-100 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Coffee className="w-4 h-4 text-amber-600" />
|
||||
<span className="text-sm font-medium text-amber-800">
|
||||
Negocio de Cafetería Detectado
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700">
|
||||
Hemos detectado que tu negocio se enfoca principalmente en bebidas y comida ligera.
|
||||
El sistema se optimizará para gestión de inventario de cafetería.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useInventory, useInventoryProducts } from '../../api/hooks/useInventory';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
@@ -37,10 +37,13 @@ const SalesAnalyticsDashboard: React.FC = () => {
|
||||
const {
|
||||
getSalesAnalytics,
|
||||
getSalesData,
|
||||
getProductsList,
|
||||
isLoading: salesLoading,
|
||||
error: salesError
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
getProductsList
|
||||
} = useInventoryProducts();
|
||||
|
||||
const {
|
||||
items: products,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Complete dashboard hook using your API infrastructure
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth, useSales, useExternal, useForecast } from '../api';
|
||||
import { useAuth, useSales, useExternal, useForecast, useInventoryProducts } from '../api';
|
||||
import type { ProductInfo } from '../api/types';
|
||||
|
||||
import { useTenantId } from './useTenantId';
|
||||
|
||||
@@ -14,6 +15,7 @@ interface DashboardData {
|
||||
} | null;
|
||||
todayForecasts: Array<{
|
||||
product: string;
|
||||
inventory_product_id: string;
|
||||
predicted: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
change: number;
|
||||
@@ -24,18 +26,22 @@ interface DashboardData {
|
||||
accuracy: number;
|
||||
stockouts: number;
|
||||
} | null;
|
||||
products: string[];
|
||||
products: ProductInfo[];
|
||||
}
|
||||
|
||||
export const useDashboard = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
getProductsList,
|
||||
getSalesAnalytics,
|
||||
getDashboardStats,
|
||||
isLoading: salesLoading,
|
||||
error: salesError
|
||||
} = useSales();
|
||||
const {
|
||||
getProductsList,
|
||||
isLoading: inventoryLoading,
|
||||
error: inventoryError
|
||||
} = useInventoryProducts();
|
||||
const {
|
||||
getCurrentWeather,
|
||||
isLoading: externalLoading,
|
||||
@@ -83,19 +89,31 @@ export const useDashboard = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Get available products
|
||||
let products: string[] = [];
|
||||
// 1. Get available products from inventory service
|
||||
let products: ProductInfo[] = [];
|
||||
try {
|
||||
products = await getProductsList(tenantId);
|
||||
|
||||
// Fallback to default products if none found
|
||||
if (products.length === 0) {
|
||||
products = ['Croissants', 'Pan de molde', 'Baguettes', 'Café', 'Napolitanas'];
|
||||
console.warn('No products found from API, using default products');
|
||||
products = [
|
||||
{ inventory_product_id: 'fallback-croissants', name: 'Croissants' },
|
||||
{ inventory_product_id: 'fallback-pan', name: 'Pan de molde' },
|
||||
{ inventory_product_id: 'fallback-baguettes', name: 'Baguettes' },
|
||||
{ inventory_product_id: 'fallback-cafe', name: 'Café' },
|
||||
{ inventory_product_id: 'fallback-napolitanas', name: 'Napolitanas' }
|
||||
];
|
||||
console.warn('No products found from inventory API, using default products');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch products:', error);
|
||||
products = ['Croissants', 'Pan de molde', 'Baguettes', 'Café', 'Napolitanas'];
|
||||
console.warn('Failed to fetch products from inventory:', error);
|
||||
products = [
|
||||
{ inventory_product_id: 'fallback-croissants', name: 'Croissants' },
|
||||
{ inventory_product_id: 'fallback-pan', name: 'Pan de molde' },
|
||||
{ inventory_product_id: 'fallback-baguettes', name: 'Baguettes' },
|
||||
{ inventory_product_id: 'fallback-cafe', name: 'Café' },
|
||||
{ inventory_product_id: 'fallback-napolitanas', name: 'Napolitanas' }
|
||||
];
|
||||
}
|
||||
// 2. Get weather data (Madrid coordinates)
|
||||
let weather = null;
|
||||
@@ -112,11 +130,10 @@ export const useDashboard = () => {
|
||||
}
|
||||
|
||||
// 3. Generate forecasts for each product
|
||||
const forecastPromises = products.map(async (product) => {
|
||||
const forecastPromises = products.map(async (productInfo) => {
|
||||
try {
|
||||
const forecastRequest = {
|
||||
inventory_product_id: product, // Use product as inventory_product_id
|
||||
product_name: product, // Keep for backward compatibility
|
||||
inventory_product_id: productInfo.inventory_product_id, // ✅ Now using actual inventory product ID
|
||||
forecast_date: new Date().toISOString().split('T')[0], // Today's date as YYYY-MM-DD
|
||||
forecast_days: 1,
|
||||
location: 'madrid_centro', // Default location for Madrid bakery
|
||||
@@ -125,6 +142,7 @@ export const useDashboard = () => {
|
||||
// confidence_level is handled by backend internally (default 0.8)
|
||||
};
|
||||
|
||||
console.log(`🔮 Requesting forecast for ${productInfo.name} (${productInfo.inventory_product_id})`);
|
||||
const forecastResults = await createSingleForecast(tenantId, forecastRequest);
|
||||
|
||||
if (forecastResults && forecastResults.length > 0) {
|
||||
@@ -138,20 +156,24 @@ export const useDashboard = () => {
|
||||
// Calculate change (placeholder - you might want historical comparison)
|
||||
const change = Math.round(Math.random() * 20 - 10);
|
||||
|
||||
console.log(`✅ Forecast successful for ${productInfo.name}: ${forecast.predicted_demand}`);
|
||||
|
||||
return {
|
||||
product,
|
||||
product: productInfo.name,
|
||||
inventory_product_id: productInfo.inventory_product_id,
|
||||
predicted: Math.round(forecast.predicted_demand || 0),
|
||||
confidence,
|
||||
change
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Forecast failed for ${product}:`, error);
|
||||
console.warn(`❌ Forecast failed for ${productInfo.name} (${productInfo.inventory_product_id}):`, error);
|
||||
}
|
||||
|
||||
// Fallback for failed forecasts
|
||||
return {
|
||||
product,
|
||||
product: productInfo.name,
|
||||
inventory_product_id: productInfo.inventory_product_id,
|
||||
predicted: Math.round(Math.random() * 50 + 20),
|
||||
confidence: 'medium' as const,
|
||||
change: Math.round(Math.random() * 20 - 10)
|
||||
@@ -213,11 +235,11 @@ export const useDashboard = () => {
|
||||
precipitation: 0
|
||||
},
|
||||
todayForecasts: [
|
||||
{ product: 'Croissants', predicted: 48, confidence: 'high', change: 8 },
|
||||
{ product: 'Pan de molde', predicted: 35, confidence: 'high', change: 3 },
|
||||
{ product: 'Baguettes', predicted: 25, confidence: 'medium', change: -3 },
|
||||
{ product: 'Café', predicted: 72, confidence: 'high', change: 5 },
|
||||
{ product: 'Napolitanas', predicted: 26, confidence: 'medium', change: 3 }
|
||||
{ product: 'Croissants', inventory_product_id: 'fallback-croissants', predicted: 48, confidence: 'high', change: 8 },
|
||||
{ product: 'Pan de molde', inventory_product_id: 'fallback-pan', predicted: 35, confidence: 'high', change: 3 },
|
||||
{ product: 'Baguettes', inventory_product_id: 'fallback-baguettes', predicted: 25, confidence: 'medium', change: -3 },
|
||||
{ product: 'Café', inventory_product_id: 'fallback-cafe', predicted: 72, confidence: 'high', change: 5 },
|
||||
{ product: 'Napolitanas', inventory_product_id: 'fallback-napolitanas', predicted: 26, confidence: 'medium', change: 3 }
|
||||
],
|
||||
metrics: {
|
||||
totalSales: 1247,
|
||||
@@ -225,7 +247,13 @@ export const useDashboard = () => {
|
||||
accuracy: 87.2,
|
||||
stockouts: 2
|
||||
},
|
||||
products: ['Croissants', 'Pan de molde', 'Baguettes', 'Café', 'Napolitanas']
|
||||
products: [
|
||||
{ inventory_product_id: 'fallback-croissants', name: 'Croissants' },
|
||||
{ inventory_product_id: 'fallback-pan', name: 'Pan de molde' },
|
||||
{ inventory_product_id: 'fallback-baguettes', name: 'Baguettes' },
|
||||
{ inventory_product_id: 'fallback-cafe', name: 'Café' },
|
||||
{ inventory_product_id: 'fallback-napolitanas', name: 'Napolitanas' }
|
||||
]
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -241,8 +269,8 @@ export const useDashboard = () => {
|
||||
|
||||
return {
|
||||
...dashboardData,
|
||||
isLoading: isLoading || salesLoading || externalLoading || forecastLoading,
|
||||
error: error || salesError || externalError || forecastError,
|
||||
isLoading: isLoading || salesLoading || inventoryLoading || externalLoading || forecastLoading,
|
||||
error: error || salesError || inventoryError || externalError || forecastError,
|
||||
reload: () => tenantId ? loadDashboardData(tenantId) : Promise.resolve(),
|
||||
clearError: () => setError(null)
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Real API hook for Order Suggestions using backend data
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSales, useExternal, useForecast } from '../api';
|
||||
import { useSales, useExternal, useForecast, useInventoryProducts } from '../api';
|
||||
import { useTenantId } from './useTenantId';
|
||||
import type { DailyOrderItem, WeeklyOrderItem } from '../components/simple/OrderSuggestions';
|
||||
|
||||
@@ -42,10 +42,12 @@ export const useOrderSuggestions = () => {
|
||||
|
||||
console.log('🏢 OrderSuggestions: Tenant info:', { tenantId, tenantLoading, tenantError });
|
||||
const {
|
||||
getProductsList,
|
||||
getSalesAnalytics,
|
||||
getDashboardStats
|
||||
} = useSales();
|
||||
const {
|
||||
getProductsList
|
||||
} = useInventoryProducts();
|
||||
const {
|
||||
getCurrentWeather
|
||||
} = useExternal();
|
||||
|
||||
@@ -144,11 +144,8 @@ export const useTenantId = () => {
|
||||
const apiTenantId = await fetchTenantIdFromAPI();
|
||||
|
||||
if (!apiTenantId) {
|
||||
console.log('❌ TenantId: No tenant found, using fallback');
|
||||
// Use the tenant ID from the logs as fallback
|
||||
const fallbackTenantId = 'bd5261b9-dc52-4d5c-b378-faaf440d9b58';
|
||||
storeTenantId(fallbackTenantId);
|
||||
setError(null);
|
||||
console.log('❌ TenantId: No tenant found for this user');
|
||||
setError('No tenant found for this user. Please complete onboarding or contact support.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -141,7 +141,7 @@ const RegisterPage: React.FC<RegisterPageProps> = ({ onLogin, onNavigateToLogin
|
||||
toast.success('¡Pago procesado correctamente!');
|
||||
|
||||
setFormData(prev => ({ ...prev, paymentCompleted: true }));
|
||||
setPaymentStep('completed');
|
||||
// Skip intermediate page and proceed directly to registration
|
||||
onPaymentSuccess();
|
||||
|
||||
} catch (error) {
|
||||
@@ -299,14 +299,11 @@ const RegisterPage: React.FC<RegisterPageProps> = ({ onLogin, onNavigateToLogin
|
||||
|
||||
// Move to payment step, or bypass if in development mode
|
||||
if (bypassPayment) {
|
||||
// Development bypass: simulate payment completion
|
||||
// Development bypass: simulate payment completion and proceed directly to registration
|
||||
setFormData(prev => ({ ...prev, paymentCompleted: true }));
|
||||
setPaymentStep('completed');
|
||||
toast.success('🚀 Modo desarrollo: Pago omitido');
|
||||
// Proceed directly to registration
|
||||
setTimeout(() => {
|
||||
handleRegistrationComplete();
|
||||
}, 1500);
|
||||
// Proceed directly to registration without intermediate page
|
||||
handleRegistrationComplete();
|
||||
} else {
|
||||
setPaymentStep('payment');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Calendar, Cloud, AlertTriangle, Info } from 'lucide-react';
|
||||
import { TrendingUp, TrendingDown, Calendar, Cloud, AlertTriangle, Info, RefreshCw } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { useForecast } from '../../api/hooks/useForecast';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
import type { ForecastResponse } from '../../api/types/forecasting';
|
||||
|
||||
interface ForecastData {
|
||||
date: string;
|
||||
@@ -9,6 +13,9 @@ interface ForecastData {
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
factors: string[];
|
||||
weatherImpact?: string;
|
||||
inventory_product_id?: string;
|
||||
confidence_lower?: number;
|
||||
confidence_upper?: number;
|
||||
}
|
||||
|
||||
interface WeatherAlert {
|
||||
@@ -20,95 +27,270 @@ interface WeatherAlert {
|
||||
const ForecastPage: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecasts, setForecasts] = useState<ForecastData[]>([]);
|
||||
const [forecastData, setForecastData] = useState<ForecastData[]>([]);
|
||||
const [weatherAlert, setWeatherAlert] = useState<WeatherAlert | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
// Hooks
|
||||
const { tenantId } = useTenantId();
|
||||
const {
|
||||
forecasts,
|
||||
isLoading: forecastLoading,
|
||||
error: forecastError,
|
||||
createSingleForecast,
|
||||
getForecasts,
|
||||
getForecastAlerts,
|
||||
exportForecasts
|
||||
} = useForecast();
|
||||
const {
|
||||
items: inventoryItems,
|
||||
isLoading: inventoryLoading,
|
||||
loadItems
|
||||
} = useInventory(false); // Disable auto-load, we'll load manually
|
||||
|
||||
const products = [
|
||||
'Croissants', 'Pan de molde', 'Baguettes', 'Napolitanas',
|
||||
'Café', 'Magdalenas', 'Donuts', 'Bocadillos'
|
||||
];
|
||||
// Debug logging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('ForecastPage - inventoryItems:', inventoryItems);
|
||||
console.log('ForecastPage - inventoryLoading:', inventoryLoading);
|
||||
console.log('ForecastPage - tenantId:', tenantId);
|
||||
}
|
||||
|
||||
// Sample forecast data for the next 7 days
|
||||
const sampleForecastData = [
|
||||
{ date: '2024-11-04', croissants: 48, pan: 35, cafe: 72 },
|
||||
{ date: '2024-11-05', croissants: 52, pan: 38, cafe: 78 },
|
||||
{ date: '2024-11-06', croissants: 45, pan: 32, cafe: 65 },
|
||||
{ date: '2024-11-07', croissants: 41, pan: 29, cafe: 58 },
|
||||
{ date: '2024-11-08', croissants: 56, pan: 42, cafe: 82 },
|
||||
{ date: '2024-11-09', croissants: 61, pan: 45, cafe: 89 },
|
||||
{ date: '2024-11-10', croissants: 38, pan: 28, cafe: 55 },
|
||||
];
|
||||
// Derived state
|
||||
const isLoading = forecastLoading || inventoryLoading;
|
||||
const products = (inventoryItems || []).map(item => ({
|
||||
id: item.id,
|
||||
name: item.name || 'Unknown Product'
|
||||
}));
|
||||
|
||||
// Sample forecast data for the next 7 days - will be populated by real data
|
||||
const [sampleForecastData, setSampleForecastData] = useState<any[]>(() => {
|
||||
// Generate 7 days starting from today
|
||||
const data = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + i);
|
||||
data.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
// Load inventory items on component mount
|
||||
useEffect(() => {
|
||||
const loadForecasts = async () => {
|
||||
setIsLoading(true);
|
||||
if (tenantId) {
|
||||
loadItems();
|
||||
}
|
||||
}, [tenantId, loadItems]);
|
||||
|
||||
// Transform API forecasts to our local format
|
||||
const transformForecastResponse = (forecast: ForecastResponse): ForecastData => {
|
||||
// Find product name from inventory items
|
||||
const inventoryItem = (inventoryItems || []).find(item => item.id === forecast.inventory_product_id);
|
||||
const productName = inventoryItem?.name || 'Unknown Product';
|
||||
|
||||
// Determine confidence level based on confidence_level number
|
||||
let confidence: 'high' | 'medium' | 'low' = 'medium';
|
||||
if (forecast.confidence_level) {
|
||||
if (forecast.confidence_level >= 0.8) confidence = 'high';
|
||||
else if (forecast.confidence_level >= 0.6) confidence = 'medium';
|
||||
else confidence = 'low';
|
||||
}
|
||||
|
||||
// Extract factors from features_used or provide defaults
|
||||
const factors = [];
|
||||
if (forecast.features_used) {
|
||||
if (forecast.features_used.is_weekend === false) factors.push('Día laboral');
|
||||
else if (forecast.features_used.is_weekend === true) factors.push('Fin de semana');
|
||||
|
||||
if (forecast.features_used.is_holiday === false) factors.push('Sin eventos especiales');
|
||||
else if (forecast.features_used.is_holiday === true) factors.push('Día festivo');
|
||||
|
||||
if (forecast.features_used.weather_description) factors.push(`Clima: ${forecast.features_used.weather_description}`);
|
||||
else factors.push('Clima estable');
|
||||
} else {
|
||||
factors.push('Día laboral', 'Clima estable', 'Sin eventos especiales');
|
||||
}
|
||||
|
||||
// Determine weather impact
|
||||
let weatherImpact = 'Sin impacto significativo';
|
||||
if (forecast.features_used?.temperature) {
|
||||
const temp = forecast.features_used.temperature;
|
||||
if (temp < 10) weatherImpact = 'Temperatura baja - posible aumento en bebidas calientes';
|
||||
else if (temp > 25) weatherImpact = 'Temperatura alta - posible reducción en productos horneados';
|
||||
}
|
||||
|
||||
return {
|
||||
date: forecast.forecast_date.split('T')[0], // Convert to YYYY-MM-DD
|
||||
product: productName,
|
||||
predicted: Math.round(forecast.predicted_demand),
|
||||
confidence,
|
||||
factors,
|
||||
weatherImpact,
|
||||
inventory_product_id: forecast.inventory_product_id,
|
||||
confidence_lower: forecast.confidence_lower,
|
||||
confidence_upper: forecast.confidence_upper,
|
||||
};
|
||||
};
|
||||
|
||||
// Generate forecasts for available products
|
||||
const generateForecasts = async () => {
|
||||
if (!tenantId || !inventoryItems || inventoryItems.length === 0) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Generate forecasts for top 3 products for the next 7 days
|
||||
const productsToForecast = inventoryItems.slice(0, 3);
|
||||
const chartData = [];
|
||||
|
||||
// Generate data for the next 7 days
|
||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||
const forecastDate = new Date();
|
||||
forecastDate.setDate(forecastDate.getDate() + dayOffset);
|
||||
const dateStr = forecastDate.toISOString().split('T')[0];
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
};
|
||||
|
||||
// Generate forecasts for each product for this day
|
||||
const dayForecasts = await Promise.all(
|
||||
productsToForecast.map(async (item) => {
|
||||
try {
|
||||
const forecastResponses = await createSingleForecast(tenantId, {
|
||||
inventory_product_id: item.id,
|
||||
forecast_date: dateStr,
|
||||
forecast_days: 1,
|
||||
location: 'Madrid, Spain',
|
||||
include_external_factors: true,
|
||||
confidence_intervals: true,
|
||||
});
|
||||
|
||||
return forecastResponses.map(transformForecastResponse);
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate forecast for ${item.name} on ${dateStr}:`, error);
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Process forecasts for this day
|
||||
const flatDayForecasts = dayForecasts.flat();
|
||||
flatDayForecasts.forEach((forecast) => {
|
||||
const key = forecast.product.toLowerCase();
|
||||
if (key.includes('croissant')) dayData.croissants = forecast.predicted;
|
||||
else if (key.includes('pan')) dayData.pan = forecast.predicted;
|
||||
else if (key.includes('cafe')) dayData.cafe = forecast.predicted;
|
||||
});
|
||||
|
||||
chartData.push(dayData);
|
||||
|
||||
// Store forecasts for selected date display
|
||||
if (dateStr === selectedDate) {
|
||||
setForecastData(flatDayForecasts);
|
||||
}
|
||||
}
|
||||
|
||||
// Update chart with 7 days of data
|
||||
setSampleForecastData(chartData);
|
||||
|
||||
// Set a sample weather alert
|
||||
setWeatherAlert({
|
||||
type: 'rain',
|
||||
impact: 'Condiciones climáticas estables para el día seleccionado',
|
||||
recommendation: 'Mantener la producción según las predicciones'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating forecasts:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load existing forecasts when component mounts or date changes
|
||||
useEffect(() => {
|
||||
const loadExistingForecasts = async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
// Try to get existing forecasts first
|
||||
const existingForecasts = await getForecasts(tenantId);
|
||||
|
||||
// Mock weather alert
|
||||
setWeatherAlert({
|
||||
type: 'rain',
|
||||
impact: 'Se esperan lluvias moderadas mañana',
|
||||
recommendation: 'Reduce la producción de productos frescos en un 20%'
|
||||
});
|
||||
|
||||
// Mock forecast data
|
||||
const mockForecasts: ForecastData[] = [
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Croissants',
|
||||
predicted: 48,
|
||||
confidence: 'high',
|
||||
factors: ['Día laboral', 'Clima estable', 'Sin eventos especiales'],
|
||||
weatherImpact: 'Sin impacto significativo'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Pan de molde',
|
||||
predicted: 35,
|
||||
confidence: 'high',
|
||||
factors: ['Demanda constante', 'Histórico estable'],
|
||||
weatherImpact: 'Sin impacto'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Café',
|
||||
predicted: 72,
|
||||
confidence: 'medium',
|
||||
factors: ['Temperatura fresca', 'Día laboral'],
|
||||
weatherImpact: 'Aumento del 10% por temperatura'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Baguettes',
|
||||
predicted: 28,
|
||||
confidence: 'medium',
|
||||
factors: ['Día entre semana', 'Demanda normal'],
|
||||
weatherImpact: 'Sin impacto'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Napolitanas',
|
||||
predicted: 23,
|
||||
confidence: 'low',
|
||||
factors: ['Variabilidad alta', 'Datos limitados'],
|
||||
weatherImpact: 'Posible reducción del 5%'
|
||||
console.log('🔍 ForecastPage - existingForecasts:', existingForecasts);
|
||||
console.log('🔍 ForecastPage - existingForecasts type:', typeof existingForecasts);
|
||||
console.log('🔍 ForecastPage - existingForecasts isArray:', Array.isArray(existingForecasts));
|
||||
|
||||
if (Array.isArray(existingForecasts) && existingForecasts.length > 0) {
|
||||
// Filter forecasts for selected date
|
||||
const dateForecasts = existingForecasts
|
||||
.filter(f => f.forecast_date && f.forecast_date.split('T')[0] === selectedDate)
|
||||
.map(transformForecastResponse);
|
||||
|
||||
if (dateForecasts.length > 0) {
|
||||
setForecastData(dateForecasts);
|
||||
}
|
||||
];
|
||||
|
||||
setForecasts(mockForecasts);
|
||||
// Update 7-day chart with existing forecasts
|
||||
const chartData = [];
|
||||
for (let dayOffset = 0; dayOffset < 7; dayOffset++) {
|
||||
const forecastDate = new Date();
|
||||
forecastDate.setDate(forecastDate.getDate() + dayOffset);
|
||||
const dateStr = forecastDate.toISOString().split('T')[0];
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
croissants: 0,
|
||||
pan: 0,
|
||||
cafe: 0
|
||||
};
|
||||
|
||||
// Find existing forecasts for this day
|
||||
const dayForecasts = existingForecasts
|
||||
.filter(f => f.forecast_date && f.forecast_date.split('T')[0] === dateStr)
|
||||
.map(transformForecastResponse);
|
||||
|
||||
dayForecasts.forEach((forecast) => {
|
||||
const key = forecast.product.toLowerCase();
|
||||
if (key.includes('croissant')) dayData.croissants = forecast.predicted;
|
||||
else if (key.includes('pan')) dayData.pan = forecast.predicted;
|
||||
else if (key.includes('cafe')) dayData.cafe = forecast.predicted;
|
||||
});
|
||||
|
||||
chartData.push(dayData);
|
||||
}
|
||||
|
||||
setSampleForecastData(chartData);
|
||||
} else {
|
||||
console.log('🔍 ForecastPage - No existing forecasts found or invalid format');
|
||||
}
|
||||
|
||||
// Load alerts
|
||||
const alerts = await getForecastAlerts(tenantId);
|
||||
if (Array.isArray(alerts) && alerts.length > 0) {
|
||||
// Convert first alert to weather alert format
|
||||
const alert = alerts[0];
|
||||
setWeatherAlert({
|
||||
type: 'rain', // Default type
|
||||
impact: alert.message || 'Alert information not available',
|
||||
recommendation: 'Revisa las recomendaciones del sistema'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading forecasts:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.error('Error loading existing forecasts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadForecasts();
|
||||
}, [selectedDate]);
|
||||
if (inventoryItems && inventoryItems.length > 0) {
|
||||
loadExistingForecasts();
|
||||
}
|
||||
}, [tenantId, selectedDate, inventoryItems, getForecasts, getForecastAlerts]);
|
||||
|
||||
const getConfidenceColor = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
@@ -137,8 +319,8 @@ const ForecastPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const filteredForecasts = selectedProduct === 'all'
|
||||
? forecasts
|
||||
: forecasts.filter(f => f.product.toLowerCase().includes(selectedProduct.toLowerCase()));
|
||||
? forecastData
|
||||
: forecastData.filter(f => f.product.toLowerCase().includes(selectedProduct.toLowerCase()));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -185,7 +367,7 @@ const ForecastPage: React.FC = () => {
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fecha de predicción
|
||||
@@ -214,11 +396,54 @@ const ForecastPage: React.FC = () => {
|
||||
>
|
||||
<option value="all">Todos los productos</option>
|
||||
{products.map(product => (
|
||||
<option key={product} value={product.toLowerCase()}>{product}</option>
|
||||
<option key={product.id} value={product.name.toLowerCase()}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Generar predicciones
|
||||
</label>
|
||||
<button
|
||||
onClick={generateForecasts}
|
||||
disabled={isGenerating || !tenantId || !(inventoryItems && inventoryItems.length > 0)}
|
||||
className="w-full px-4 py-3 bg-primary-500 hover:bg-primary-600 disabled:bg-gray-300 text-white rounded-xl transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Generando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingUp className="h-4 w-4 mr-2" />
|
||||
Generar Predicciones
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{forecastError && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 mr-2" />
|
||||
<span className="text-red-800 text-sm">Error: {forecastError}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{forecastData.length === 0 && !isLoading && !isGenerating && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Info className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<span className="text-blue-800 text-sm">
|
||||
No hay predicciones para la fecha seleccionada. Haz clic en "Generar Predicciones" para crear nuevas predicciones.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Forecast Cards */}
|
||||
@@ -388,19 +613,31 @@ const ForecastPage: React.FC = () => {
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<button
|
||||
onClick={() => tenantId && exportForecasts(tenantId, 'csv')}
|
||||
disabled={!tenantId || forecastData.length === 0}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Exportar Predicciones</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Descargar en formato CSV</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Recibir notificaciones automáticas</div>
|
||||
<button
|
||||
onClick={generateForecasts}
|
||||
disabled={isGenerating || !tenantId || !(inventoryItems && inventoryItems.length > 0)}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Actualizar Predicciones</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Generar nuevas predicciones</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Ver Precisión Histórica</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Analizar rendimiento del modelo</div>
|
||||
<button
|
||||
onClick={() => tenantId && getForecastAlerts(tenantId)}
|
||||
disabled={!tenantId}
|
||||
className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-gray-900">Ver Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Revisar notificaciones del sistema</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,13 @@ import SmartHistoricalDataImport from '../../components/onboarding/SmartHistoric
|
||||
|
||||
import {
|
||||
useTenant,
|
||||
useTraining,
|
||||
useSales,
|
||||
useTrainingWebSocket,
|
||||
useOnboarding,
|
||||
TenantCreate,
|
||||
TrainingJobRequest
|
||||
} from '../../api';
|
||||
import { useTraining } from '../../api/hooks/useTraining';
|
||||
|
||||
import { OnboardingRouter } from '../../utils/onboardingRouter';
|
||||
|
||||
@@ -134,7 +134,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
fetchTenantIdFromBackend();
|
||||
}, [tenantId, user, getUserTenants]);
|
||||
|
||||
// WebSocket connection for real-time training updates
|
||||
// Enhanced WebSocket connection for real-time training updates
|
||||
const {
|
||||
status,
|
||||
jobUpdates,
|
||||
@@ -143,7 +143,11 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
isConnected,
|
||||
lastMessage,
|
||||
tenantId: resolvedTenantId,
|
||||
wsUrl
|
||||
wsUrl,
|
||||
connectionError,
|
||||
isAuthenticationError,
|
||||
refreshConnection,
|
||||
retryWithAuth
|
||||
} = useTrainingWebSocket(trainingJobId || 'pending', tenantId);
|
||||
|
||||
// Handle WebSocket job updates
|
||||
@@ -203,12 +207,19 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
currentStep: 'Error en el entrenamiento'
|
||||
}));
|
||||
|
||||
} else if (messageType === 'initial_status') {
|
||||
} else if (messageType === 'initial_status' || messageType === 'current_status') {
|
||||
console.log('Received training status update:', messageType, data);
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
progress: typeof data.progress === 'number' ? data.progress : prev.progress,
|
||||
status: data.status || prev.status,
|
||||
currentStep: data.current_step || data.currentStep || prev.currentStep
|
||||
currentStep: data.current_step || data.currentStep || prev.currentStep,
|
||||
productsCompleted: data.products_completed || data.productsCompleted || prev.productsCompleted,
|
||||
productsTotal: data.products_total || data.productsTotal || prev.productsTotal,
|
||||
estimatedTimeRemaining: data.estimated_time_remaining_minutes ||
|
||||
data.estimated_time_remaining ||
|
||||
data.estimatedTimeRemaining ||
|
||||
prev.estimatedTimeRemaining
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
@@ -228,10 +239,94 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
}
|
||||
}, [jobUpdates, processWebSocketMessage]);
|
||||
|
||||
// Connect to WebSocket when training starts
|
||||
// Enhanced WebSocket connection management with polling fallback
|
||||
useEffect(() => {
|
||||
if (tenantId && trainingJobId && currentStep === 3) {
|
||||
console.log('Connecting to training WebSocket:', { tenantId, trainingJobId, wsUrl });
|
||||
connect();
|
||||
|
||||
// Simple polling fallback for training completion detection (now that we fixed the 404 issue)
|
||||
const pollingInterval = setInterval(async () => {
|
||||
if (trainingProgress.status === 'running' || trainingProgress.status === 'pending') {
|
||||
try {
|
||||
// Check training job status via REST API as fallback
|
||||
const response = await fetch(`http://localhost:8000/api/v1/tenants/${tenantId}/training/jobs/${trainingJobId}/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'X-Tenant-ID': tenantId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const jobStatus = await response.json();
|
||||
|
||||
// If the job is completed but we haven't received WebSocket notification
|
||||
if (jobStatus.status === 'completed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
|
||||
console.log('Training completed detected via REST polling fallback');
|
||||
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
currentStep: 'Entrenamiento completado',
|
||||
estimatedTimeRemaining: 0
|
||||
}));
|
||||
|
||||
// Mark training step as completed in onboarding API
|
||||
completeStep('training_completed', {
|
||||
training_completed_at: new Date().toISOString(),
|
||||
user_id: user?.id,
|
||||
tenant_id: tenantId,
|
||||
completion_detected_via: 'rest_polling_fallback'
|
||||
}).catch(error => {
|
||||
console.warn('Failed to mark training as completed in API:', error);
|
||||
});
|
||||
|
||||
// Show celebration and auto-advance to final step after 3 seconds
|
||||
toast.success('🎉 Training completed! Your AI model is ready to use.', {
|
||||
duration: 5000,
|
||||
icon: '🤖'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(4);
|
||||
}, 3000);
|
||||
|
||||
// Clear the polling interval
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
|
||||
// If job failed, update status
|
||||
if (jobStatus.status === 'failed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
|
||||
console.log('Training failure detected via REST polling fallback');
|
||||
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
status: 'failed',
|
||||
error: jobStatus.error_message || 'Error en el entrenamiento',
|
||||
currentStep: 'Error en el entrenamiento'
|
||||
}));
|
||||
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore polling errors to avoid noise
|
||||
console.debug('REST polling error (expected if training not started):', error);
|
||||
}
|
||||
} else if (trainingProgress.status === 'completed' || trainingProgress.status === 'failed') {
|
||||
// Clear polling if training is finished
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
}, 15000); // Poll every 15 seconds (less aggressive than before)
|
||||
|
||||
return () => {
|
||||
if (isConnected) {
|
||||
disconnect();
|
||||
}
|
||||
clearInterval(pollingInterval);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -239,7 +334,35 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
}, [tenantId, trainingJobId, currentStep, connect, disconnect, isConnected]);
|
||||
}, [tenantId, trainingJobId, currentStep]); // Removed problematic dependencies that cause reconnection loops
|
||||
|
||||
// Handle connection errors with user feedback
|
||||
useEffect(() => {
|
||||
if (connectionError) {
|
||||
if (isAuthenticationError) {
|
||||
toast.error('Sesión expirada. Reintentando conexión...');
|
||||
// Auto-retry authentication errors after 3 seconds
|
||||
setTimeout(() => {
|
||||
retryWithAuth();
|
||||
}, 3000);
|
||||
} else {
|
||||
console.warn('WebSocket connection error:', connectionError);
|
||||
// Don't show error toast for non-auth errors as they auto-retry
|
||||
}
|
||||
}
|
||||
}, [connectionError, isAuthenticationError, retryWithAuth]);
|
||||
|
||||
// Enhanced WebSocket status logging
|
||||
useEffect(() => {
|
||||
console.log('WebSocket status changed:', {
|
||||
status,
|
||||
isConnected,
|
||||
jobId: trainingJobId,
|
||||
tenantId,
|
||||
connectionError,
|
||||
isAuthenticationError
|
||||
});
|
||||
}, [status, isConnected, trainingJobId, tenantId, connectionError, isAuthenticationError]);
|
||||
|
||||
|
||||
const storeTenantId = (tenantId: string) => {
|
||||
@@ -632,6 +755,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
||||
error: trainingProgress.error
|
||||
}}
|
||||
websocketStatus={status}
|
||||
connectionError={connectionError}
|
||||
isConnected={isConnected}
|
||||
onRetryConnection={refreshConnection}
|
||||
onTimeout={() => {
|
||||
toast.success('El entrenamiento continuará en segundo plano. ¡Puedes empezar a explorar!');
|
||||
onComplete(); // Navigate to dashboard
|
||||
|
||||
Reference in New Issue
Block a user