From f7de9115d1530ae5486ec6d2c1e6f0f9bb06bcd3 Mon Sep 17 00:00:00 2001
From: Urtzi Alfaro
Date: Fri, 15 Aug 2025 17:53:59 +0200
Subject: [PATCH] Fix new services implementation 5
---
frontend/src/App.tsx | 10 +-
frontend/src/api/client/interceptors.ts | 19 +-
frontend/src/api/hooks/index.ts | 7 +-
frontend/src/api/hooks/useForecast.ts | 8 +-
frontend/src/api/hooks/useInventory.ts | 85 +++-
frontend/src/api/hooks/useSales.ts | 20 -
frontend/src/api/index.ts | 3 +
frontend/src/api/services/index.ts | 1 +
.../src/api/services/inventory.service.ts | 176 +++++++-
frontend/src/api/services/sales.service.ts | 79 ----
frontend/src/api/services/tenant.service.ts | 15 +-
frontend/src/api/types/data.ts | 10 +-
frontend/src/api/websocket/hooks.ts | 302 ++++++++++---
frontend/src/api/websocket/manager.ts | 11 +-
.../components/SimplifiedTrainingProgress.tsx | 51 ++-
frontend/src/components/layout/Layout.tsx | 6 +-
.../onboarding/SmartHistoricalDataImport.tsx | 59 ++-
.../sales/SalesAnalyticsDashboard.tsx | 7 +-
frontend/src/hooks/useDashboard.ts | 74 +++-
frontend/src/hooks/useOrderSuggestions.ts | 6 +-
frontend/src/hooks/useTenantId.ts | 7 +-
frontend/src/pages/auth/RegisterPage.tsx | 11 +-
frontend/src/pages/forecast/ForecastPage.tsx | 411 ++++++++++++++----
.../src/pages/onboarding/OnboardingPage.tsx | 141 +++++-
gateway/app/main.py | 101 ++---
gateway/requirements.txt | 3 +-
.../external/apis/madrid_traffic_client.py | 23 +-
.../app/external/clients/madrid_client.py | 19 +-
.../app/repositories/traffic_repository.py | 20 +-
.../app/services/forecasting_service.py | 18 +-
.../app/repositories/ingredient_repository.py | 7 +
services/sales/app/api/sales.py | 22 -
.../app/repositories/sales_repository.py | 26 +-
.../app/services/ai_onboarding_service.py | 148 ++++++-
.../sales/app/services/data_import_service.py | 216 +++++++--
services/sales/app/services/sales_service.py | 20 -
services/training/app/api/training.py | 18 +-
services/training/app/ml/trainer.py | 26 +-
.../app/services/training_orchestrator.py | 9 +-
.../training/app/services/training_service.py | 89 +++-
shared/clients/training_client.py | 6 +-
test_onboarding_debug.py | 162 -------
test_sales_import_fix.py | 153 -------
43 files changed, 1714 insertions(+), 891 deletions(-)
delete mode 100644 test_onboarding_debug.py
delete mode 100644 test_sales_import_fix.py
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 18d00bd7..f44940d8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 ;
case 'production':
return ;
+ case 'inventory':
+ return ;
+ case 'sales':
+ return ;
case 'settings':
return ;
default:
@@ -298,6 +304,8 @@ const App: React.FC = () => {
onNavigateToOrders={() => navigateTo('orders')}
onNavigateToReports={() => navigateTo('reports')}
onNavigateToProduction={() => navigateTo('production')}
+ onNavigateToInventory={() => navigateTo('inventory')}
+ onNavigateToSales={() => navigateTo('sales')}
/>;
}
};
diff --git a/frontend/src/api/client/interceptors.ts b/frontend/src/api/client/interceptors.ts
index ad3e2a5a..e58b71a7 100644
--- a/frontend/src/api/client/interceptors.ts
+++ b/frontend/src/api/client/interceptors.ts
@@ -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;
diff --git a/frontend/src/api/hooks/index.ts b/frontend/src/api/hooks/index.ts
index 28fff518..e91ed69f 100644
--- a/frontend/src/api/hooks/index.ts
+++ b/frontend/src/api/hooks/index.ts
@@ -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
};
};
\ No newline at end of file
diff --git a/frontend/src/api/hooks/useForecast.ts b/frontend/src/api/hooks/useForecast.ts
index a8ee1fc8..3696f832 100644
--- a/frontend/src/api/hooks/useForecast.ts
+++ b/frontend/src/api/hooks/useForecast.ts
@@ -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) {
diff --git a/frontend/src/api/hooks/useInventory.ts b/frontend/src/api/hooks/useInventory.ts
index d7820c05..2b422917 100644
--- a/frontend/src/api/hooks/useInventory.ts
+++ b/frontend/src/api/hooks/useInventory.ts
@@ -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(null);
+
+ /**
+ * Get Products List for Forecasting
+ */
+ const getProductsList = useCallback(async (tenantId: string): Promise => {
+ 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 => {
+ 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,
+ };
};
\ No newline at end of file
diff --git a/frontend/src/api/hooks/useSales.ts b/frontend/src/api/hooks/useSales.ts
index 0913a46c..cbfa3fa3 100644
--- a/frontend/src/api/hooks/useSales.ts
+++ b/frontend/src/api/hooks/useSales.ts
@@ -156,25 +156,6 @@ export const useSales = () => {
}
}, []);
- /**
- * Get Products List
- */
- const getProductsList = useCallback(async (tenantId: string): Promise => {
- 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),
};
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index 94d47554..701c823f 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -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
diff --git a/frontend/src/api/services/index.ts b/frontend/src/api/services/index.ts
index 1b8c0a0e..68a2a4c5 100644
--- a/frontend/src/api/services/index.ts
+++ b/frontend/src/api/services/index.ts
@@ -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' },
];
diff --git a/frontend/src/api/services/inventory.service.ts b/frontend/src/api/services/inventory.service.ts
index c9d7e8b7..327e7f30 100644
--- a/frontend/src/api/services/inventory.service.ts
+++ b/frontend/src/api/services/inventory.service.ts
@@ -5,6 +5,7 @@
*/
import { apiClient } from '../client';
+import type { ProductInfo } from '../types';
// ========== TYPES AND INTERFACES ==========
@@ -208,7 +209,7 @@ export interface PaginatedResponse {
// ========== 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
- 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 {
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 {
- 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 {
- 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 {
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
}
+
+ // ========== PRODUCTS FOR FORECASTING ==========
+
+ /**
+ * Get Products List with IDs for Forecasting
+ */
+ async getProductsList(tenantId: string): Promise {
+ 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 {
+ 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();
\ No newline at end of file
diff --git a/frontend/src/api/services/sales.service.ts b/frontend/src/api/services/sales.service.ts
index f179bb05..e71cefbc 100644
--- a/frontend/src/api/services/sales.service.ts
+++ b/frontend/src/api/services/sales.service.ts
@@ -181,85 +181,6 @@ export class SalesService {
});
}
- /**
- * Get Products List from Sales Data
- */
- async getProductsList(tenantId: string): Promise {
- 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
diff --git a/frontend/src/api/services/tenant.service.ts b/frontend/src/api/services/tenant.service.ts
index ecca4083..0fd6e89c 100644
--- a/frontend/src/api/services/tenant.service.ts
+++ b/frontend/src/api/services/tenant.service.ts
@@ -91,10 +91,21 @@ export class TenantService {
}
/**
- * Get User's Tenants
+ * Get User's Tenants - Get tenants where user is owner
*/
async getUserTenants(): Promise {
- 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 [];
+ }
}
}
diff --git a/frontend/src/api/types/data.ts b/frontend/src/api/types/data.ts
index f155ebe8..a13891e6 100644
--- a/frontend/src/api/types/data.ts
+++ b/frontend/src/api/types/data.ts
@@ -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;
}
diff --git a/frontend/src/api/websocket/hooks.ts b/frontend/src/api/websocket/hooks.ts
index 3779d8c3..5fd80575 100644
--- a/frontend/src/api/websocket/hooks.ts
+++ b/frontend/src/api/websocket/hooks.ts
@@ -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([]);
+ const [connectionError, setConnectionError] = useState(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])
};
};
diff --git a/frontend/src/api/websocket/manager.ts b/frontend/src/api/websocket/manager.ts
index 2f59420d..e1869da9 100644
--- a/frontend/src/api/websocket/manager.ts
+++ b/frontend/src/api/websocket/manager.ts
@@ -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?.();
}
};
diff --git a/frontend/src/components/SimplifiedTrainingProgress.tsx b/frontend/src/components/SimplifiedTrainingProgress.tsx
index 6d97f9d5..6b95924d 100644
--- a/frontend/src/components/SimplifiedTrainingProgress.tsx
+++ b/frontend/src/components/SimplifiedTrainingProgress.tsx
@@ -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(null);
const [startTime] = useState(Date.now());
+ const celebratedStagesRef = useRef>(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({
+ {/* Connection Status Debug Info */}
+ {(websocketStatus || connectionError) && (
+
+
+
+ Estado de conexión:
+ {connectionError
+ ? ` Error - ${connectionError}`
+ : isConnected
+ ? ' ✅ Conectado a tiempo real'
+ : ' ⏳ Conectando...'}
+
+ {connectionError && onRetryConnection && (
+
+ )}
+
+
+ )}
+
{/* Optional Details */}