From c8b1a941f88cbb542f4f520f8ef20752f8f5ceb3 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Mon, 8 Sep 2025 21:44:04 +0200 Subject: [PATCH] Simplify the onboardinf flow components 2 --- frontend/src/api/hooks/training.ts | 301 +++++++++---- frontend/src/api/types/dataImport.ts | 15 +- frontend/src/api/types/inventory.ts | 20 +- .../domain/onboarding/OnboardingWizard.tsx | 409 ++++++++++++++---- .../onboarding/steps/MLTrainingStep.tsx | 110 +++-- .../onboarding/steps/UploadSalesDataStep.tsx | 140 ++++-- 6 files changed, 726 insertions(+), 269 deletions(-) diff --git a/frontend/src/api/hooks/training.ts b/frontend/src/api/hooks/training.ts index 2541dd52..02111cd0 100644 --- a/frontend/src/api/hooks/training.ts +++ b/frontend/src/api/hooks/training.ts @@ -3,9 +3,11 @@ * Provides data fetching, caching, and state management for training operations */ +import React from 'react'; import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; import { trainingService } from '../services/training'; import { ApiError } from '../client/apiClient'; +import { useAuthStore } from '../../stores/auth.store'; import type { TrainingJobRequest, TrainingJobResponse, @@ -240,89 +242,240 @@ export const useTrainingWebSocket = ( } ) => { const queryClient = useQueryClient(); + const authToken = useAuthStore((state) => state.token); + const [isConnected, setIsConnected] = React.useState(false); + const [connectionError, setConnectionError] = React.useState(null); - return useQuery({ - queryKey: ['training-websocket', tenantId, jobId], - queryFn: () => { - return new Promise((resolve, reject) => { - try { - const ws = trainingService.createWebSocketConnection(tenantId, jobId, token); + // Memoize options to prevent unnecessary effect re-runs + const memoizedOptions = React.useMemo(() => options, [ + options?.onProgress, + options?.onCompleted, + options?.onError, + options?.onStarted, + options?.onCancelled + ]); - ws.onopen = () => { - console.log('Training WebSocket connected'); - }; + React.useEffect(() => { + if (!tenantId || !jobId || !memoizedOptions) { + return; + } - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - - // Update job status in cache - queryClient.setQueryData( - trainingKeys.jobs.status(tenantId, jobId), - (oldData: TrainingJobStatus | undefined) => ({ - ...oldData, - job_id: jobId, - status: message.status || oldData?.status || 'running', - progress: message.progress?.percentage || oldData?.progress || 0, - message: message.message || oldData?.message || '', - current_step: message.progress?.current_step || oldData?.current_step, - estimated_time_remaining: message.progress?.estimated_time_remaining || oldData?.estimated_time_remaining, - }) - ); + let ws: WebSocket | null = null; + let reconnectTimer: NodeJS.Timeout | null = null; + let isManuallyDisconnected = false; + let reconnectAttempts = 0; + const maxReconnectAttempts = 3; - // Call appropriate callback based on message type - switch (message.type) { - case 'progress': - options?.onProgress?.(message); - break; - case 'completed': - options?.onCompleted?.(message); - // Invalidate models and statistics - queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() }); - queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) }); - resolve(message); - break; - case 'error': - options?.onError?.(message); - reject(new Error(message.error)); - break; - case 'started': - options?.onStarted?.(message); - break; - case 'cancelled': - options?.onCancelled?.(message); - resolve(message); - break; + const connect = () => { + try { + setConnectionError(null); + const effectiveToken = token || authToken; + console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, { + tenantId, + jobId, + hasToken: !!effectiveToken + }); + + ws = trainingService.createWebSocketConnection(tenantId, jobId, token || authToken || undefined); + + ws.onopen = () => { + console.log('✅ Training WebSocket connected successfully'); + setIsConnected(true); + reconnectAttempts = 0; // Reset on successful connection + + // Request current status on connection + try { + ws?.send('get_status'); + console.log('📤 Requested current training status'); + } catch (e) { + console.warn('Failed to request status on connection:', e); + } + + // Set up periodic ping to keep connection alive + const pingInterval = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) { + try { + ws?.send('ping'); + console.log('💓 Sent ping to server'); + } catch (e) { + console.warn('Failed to send ping:', e); + clearInterval(pingInterval); } - } catch (error) { - console.error('Error parsing WebSocket message:', error); - reject(error); + } else { + clearInterval(pingInterval); } - }; + }, 30000); // Ping every 30 seconds + + // Store interval for cleanup + (ws as any).pingInterval = pingInterval; + }; - ws.onerror = (error) => { - console.error('Training WebSocket error:', error); - reject(error); - }; + ws.onmessage = (event) => { + try { + // Handle non-JSON messages (like pong responses) + if (typeof event.data === 'string' && event.data === 'pong') { + console.log('🏓 Pong received from server'); + return; + } - ws.onclose = () => { - console.log('Training WebSocket disconnected'); - }; + const message = JSON.parse(event.data); + + console.log('🔔 Training WebSocket message received:', message); - // Return cleanup function - return () => { - ws.close(); - }; - } catch (error) { - reject(error); - } - }); - }, - enabled: !!tenantId && !!jobId, - refetchOnWindowFocus: false, - retry: false, - staleTime: Infinity, - }); + // Handle heartbeat messages + if (message.type === 'heartbeat') { + console.log('💓 Heartbeat received from server'); + return; // Don't process heartbeats further + } + + // Extract data from backend message structure + const eventData = message.data || {}; + const progress = eventData.progress || 0; + const currentStep = eventData.current_step || eventData.step_name || ''; + const statusMessage = eventData.message || eventData.status || ''; + + // Update job status in cache with backend structure + queryClient.setQueryData( + trainingKeys.jobs.status(tenantId, jobId), + (oldData: TrainingJobStatus | undefined) => ({ + ...oldData, + job_id: jobId, + status: message.type === 'completed' ? 'completed' : + message.type === 'failed' ? 'failed' : + message.type === 'started' ? 'running' : + oldData?.status || 'running', + progress: typeof progress === 'number' ? progress : oldData?.progress || 0, + message: statusMessage || oldData?.message || '', + current_step: currentStep || oldData?.current_step, + estimated_time_remaining: eventData.estimated_time_remaining || oldData?.estimated_time_remaining, + }) + ); + + // Call appropriate callback based on message type (exact backend mapping) + switch (message.type) { + case 'started': + memoizedOptions?.onStarted?.(message); + break; + case 'progress': + memoizedOptions?.onProgress?.(message); + break; + case 'step_completed': + memoizedOptions?.onProgress?.(message); // Treat step completion as progress + break; + case 'completed': + console.log('✅ Training completed successfully'); + memoizedOptions?.onCompleted?.(message); + // Invalidate models and statistics + queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() }); + queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) }); + isManuallyDisconnected = true; // Don't reconnect after completion + break; + case 'failed': + console.log('❌ Training failed'); + memoizedOptions?.onError?.(message); + isManuallyDisconnected = true; // Don't reconnect after failure + break; + case 'cancelled': + console.log('🛑 Training cancelled'); + memoizedOptions?.onCancelled?.(message); + isManuallyDisconnected = true; // Don't reconnect after cancellation + break; + case 'current_status': + console.log('📊 Received current training status'); + // Treat current status as progress update if it has progress data + if (message.data) { + memoizedOptions?.onProgress?.(message); + } + break; + default: + console.log(`🔍 Received unknown message type: ${message.type}`); + break; + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + setConnectionError('Error parsing message from server'); + } + }; + + ws.onerror = (error) => { + console.error('Training WebSocket error:', error); + setConnectionError('WebSocket connection error'); + setIsConnected(false); + }; + + ws.onclose = (event) => { + console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`); + setIsConnected(false); + + // Detailed logging for different close codes + switch (event.code) { + case 1000: + console.log('🔒 WebSocket closed normally'); + break; + case 1006: + console.log('⚠️ WebSocket closed abnormally (1006) - likely server-side issue or network problem'); + break; + case 1001: + console.log('🔄 WebSocket endpoint going away'); + break; + case 1003: + console.log('❌ WebSocket unsupported data received'); + break; + default: + console.log(`❓ WebSocket closed with code ${event.code}`); + } + + // Try to reconnect if not manually disconnected and haven't exceeded max attempts + if (!isManuallyDisconnected && event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) { + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s + console.log(`🔄 Attempting to reconnect WebSocket in ${delay/1000}s... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`); + + reconnectTimer = setTimeout(() => { + reconnectAttempts++; + connect(); + }, delay); + } else if (reconnectAttempts >= maxReconnectAttempts) { + console.log(`❌ Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`); + setConnectionError(`Connection failed after ${maxReconnectAttempts} attempts. The training job may not exist or the server may be unavailable.`); + } + }; + + } catch (error) { + console.error('Error creating WebSocket connection:', error); + setConnectionError('Failed to create WebSocket connection'); + } + }; + + // Delay initial connection to ensure training job is created + const initialConnectionTimer = setTimeout(() => { + console.log('🚀 Starting initial WebSocket connection...'); + connect(); + }, 2000); // 2-second delay to let the job initialize + + // Cleanup function + return () => { + isManuallyDisconnected = true; + + if (initialConnectionTimer) { + clearTimeout(initialConnectionTimer); + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + + if (ws) { + ws.close(1000, 'Component unmounted'); + } + + setIsConnected(false); + }; + }, [tenantId, jobId, token, authToken, queryClient, memoizedOptions]); + + return { + isConnected, + connectionError + }; }; // Utility Hooks diff --git a/frontend/src/api/types/dataImport.ts b/frontend/src/api/types/dataImport.ts index 836bb739..57fdf0ea 100644 --- a/frontend/src/api/types/dataImport.ts +++ b/frontend/src/api/types/dataImport.ts @@ -13,18 +13,9 @@ export interface ImportValidationResponse { total_records: number; valid_records: number; invalid_records: number; - errors: string[]; - warnings: string[]; - summary: { - status: string; - file_format: string; - file_size_bytes: number; - file_size_mb: number; - estimated_processing_time_seconds: number; - validation_timestamp: string; - detected_columns: string[]; - suggestions: string[]; - }; + errors: Array>; + warnings: Array>; + summary: Record; unique_products: number; product_list: string[]; message: string; diff --git a/frontend/src/api/types/inventory.ts b/frontend/src/api/types/inventory.ts index c90d2e51..a2ed807a 100644 --- a/frontend/src/api/types/inventory.ts +++ b/frontend/src/api/types/inventory.ts @@ -6,17 +6,17 @@ export interface IngredientCreate { name: string; description?: string; - category: string; + category?: string; unit_of_measure: string; - minimum_stock_level: number; - maximum_stock_level: number; + low_stock_threshold: number; + max_stock_level?: number; reorder_point: number; shelf_life_days?: number; requires_refrigeration?: boolean; requires_freezing?: boolean; is_seasonal?: boolean; supplier_id?: string; - cost_per_unit?: number; + average_cost?: number; notes?: string; } @@ -25,15 +25,15 @@ export interface IngredientUpdate { description?: string; category?: string; unit_of_measure?: string; - minimum_stock_level?: number; - maximum_stock_level?: number; + low_stock_threshold?: number; + max_stock_level?: number; reorder_point?: number; shelf_life_days?: number; requires_refrigeration?: boolean; requires_freezing?: boolean; is_seasonal?: boolean; supplier_id?: string; - cost_per_unit?: number; + average_cost?: number; notes?: string; } @@ -44,15 +44,15 @@ export interface IngredientResponse { description?: string; category: string; unit_of_measure: string; - minimum_stock_level: number; - maximum_stock_level: number; + low_stock_threshold: number; + max_stock_level: number; reorder_point: number; shelf_life_days?: number; requires_refrigeration: boolean; requires_freezing: boolean; is_seasonal: boolean; supplier_id?: string; - cost_per_unit?: number; + average_cost?: number; notes?: string; current_stock_level: number; available_stock: number; diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx index a2c9b566..ee1a9efd 100644 --- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx @@ -1,8 +1,9 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button } from '../../ui/Button'; +import { Card, CardHeader, CardBody } from '../../ui/Card'; import { useAuth } from '../../../contexts/AuthContext'; -import { useMarkStepCompleted } from '../../../api/hooks/onboarding'; +import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding'; import { useTenantActions } from '../../../stores/tenant.store'; import { useTenantInitializer } from '../../../stores/useTenantInitializer'; import { @@ -27,11 +28,13 @@ interface StepProps { isLastStep: boolean; } +// Steps must match backend ONBOARDING_STEPS exactly +// Note: "user_registered" is auto-completed and not shown in UI const STEPS: StepConfig[] = [ { id: 'setup', title: 'Registrar Panadería', - description: 'Configura la información de tu panadería', + description: 'Configura la información básica de tu panadería', component: RegisterTenantStep, }, { @@ -43,37 +46,136 @@ const STEPS: StepConfig[] = [ { id: 'ml-training', title: 'Entrenamiento IA', - description: 'Entrena tu modelo de inteligencia artificial', + description: 'Entrena tu modelo de inteligencia artificial personalizado', component: MLTrainingStep, }, { id: 'completion', title: 'Configuración Completa', - description: 'Bienvenido a tu sistema de gestión de panadería', + description: '¡Bienvenido a tu sistema de gestión inteligente!', component: CompletionStep, }, ]; export const OnboardingWizard: React.FC = () => { const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isInitialized, setIsInitialized] = useState(false); const navigate = useNavigate(); const { user } = useAuth(); // Initialize tenant data for authenticated users useTenantInitializer(); + // Get user progress from backend + const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress( + user?.id || '', + { enabled: !!user?.id } + ); + const markStepCompleted = useMarkStepCompleted(); const { setCurrentTenant } = useTenantActions(); + // Auto-complete user_registered step if needed (runs first) + useEffect(() => { + if (userProgress && user?.id) { + const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered'); + + if (!userRegisteredStep?.completed) { + console.log('🔄 Auto-completing user_registered step for new user...'); + + markStepCompleted.mutate({ + userId: user.id, + stepName: 'user_registered', + data: { + auto_completed: true, + completed_at: new Date().toISOString(), + source: 'onboarding_wizard_auto_completion' + } + }, { + onSuccess: () => { + console.log('✅ user_registered step auto-completed successfully'); + // The query will automatically refetch and update userProgress + }, + onError: (error) => { + console.error('❌ Failed to auto-complete user_registered step:', error); + } + }); + } + } + }, [userProgress, user?.id, markStepCompleted]); + + // Initialize step index based on backend progress with validation + useEffect(() => { + if (userProgress && !isInitialized) { + console.log('🔄 Initializing onboarding progress:', userProgress); + + // Check if user_registered step is completed + const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered'); + if (!userRegisteredStep?.completed) { + console.log('⏳ Waiting for user_registered step to be auto-completed...'); + return; // Wait for auto-completion to finish + } + + // Find the current step index based on backend progress + const currentStepFromBackend = userProgress.current_step; + let stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend); + + console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`); + + // If current step is not found (e.g., suppliers step), find the next incomplete step + if (stepIndex === -1) { + console.log('🔍 Current step not found in UI steps, finding first incomplete step...'); + + // Find the first incomplete step that user can access + for (let i = 0; i < STEPS.length; i++) { + const step = STEPS[i]; + const stepProgress = userProgress.steps.find(s => s.step_name === step.id); + + if (!stepProgress?.completed) { + stepIndex = i; + console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`); + break; + } + } + + // If all visible steps are completed, go to last step + if (stepIndex === -1) { + stepIndex = STEPS.length - 1; + console.log('✅ All steps completed, going to last step'); + } + } + + // Ensure user can't skip ahead - find the first incomplete step + const firstIncompleteStepIndex = STEPS.findIndex(step => { + const stepProgress = userProgress.steps.find(s => s.step_name === step.id); + return !stepProgress?.completed; + }); + + if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) { + console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`); + stepIndex = firstIncompleteStepIndex; + } + + console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`); + + if (stepIndex !== currentStepIndex) { + setCurrentStepIndex(stepIndex); + } + setIsInitialized(true); + } + }, [userProgress, isInitialized, currentStepIndex]); + const currentStep = STEPS[currentStepIndex]; - const handlePrevious = () => { - if (currentStepIndex > 0) { - setCurrentStepIndex(currentStepIndex - 1); - } - }; const handleStepComplete = async (data?: any) => { + if (!user?.id) { + console.error('User ID not available'); + return; + } + + console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data); + try { // Special handling for setup step - set the created tenant in tenant store if (currentStep.id === 'setup' && data?.tenant) { @@ -81,11 +183,14 @@ export const OnboardingWizard: React.FC = () => { } // Mark step as completed in backend + console.log(`📤 Sending API request to complete step: "${currentStep.id}"`); await markStepCompleted.mutateAsync({ - userId: user?.id || '', + userId: user.id, stepName: currentStep.id, data }); + + console.log(`✅ Successfully completed step: "${currentStep.id}"`); if (currentStep.id === 'completion') { navigate('/app'); @@ -95,91 +200,235 @@ export const OnboardingWizard: React.FC = () => { setCurrentStepIndex(currentStepIndex + 1); } } - } catch (error) { - console.error('Error marking step as completed:', error); + } catch (error: any) { + console.error(`❌ Error completing step "${currentStep.id}":`, error); + + // Extract detailed error information + const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error'; + const statusCode = error?.response?.status; + + console.error(`📊 Error details: Status ${statusCode}, Message: ${errorMessage}`); + + // Check if it's a dependency error + if (errorMessage.includes('dependencies not met')) { + console.error('🚫 Dependencies not met for step:', currentStep.id); + + // Check what dependencies are missing + if (userProgress) { + console.log('📋 Current progress:', userProgress); + console.log('📋 Completed steps:', userProgress.steps.filter(s => s.completed).map(s => s.step_name)); + } + } + + // Don't advance automatically on error - user should see the issue + alert(`Error al completar paso "${currentStep.title}": ${errorMessage}`); } }; + // Show loading state while initializing progress + if (isLoadingProgress || !isInitialized) { + return ( +
+ + +
+
+

Cargando tu progreso...

+
+
+
+
+ ); + } + + // Show error state if progress fails to load + if (progressError) { + return ( +
+ + +
+
+ + + +
+
+

+ Error al cargar progreso +

+

+ No pudimos cargar tu progreso de configuración. Puedes continuar desde el inicio. +

+ +
+
+
+
+
+ ); + } + const StepComponent = currentStep.component; + const progressPercentage = userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100; return ( -
- {/* Progress Bar */} -
-
-

- Bienvenido a Bakery IA -

- - Paso {currentStepIndex + 1} de {STEPS.length} - +
+ {/* Enhanced Progress Header */} + +
+
+

+ Bienvenido a Bakery IA +

+

+ Configura tu sistema de gestión inteligente paso a paso +

+
+
+
+ Paso {currentStepIndex + 1} de {STEPS.length} +
+
+ {Math.round(progressPercentage)}% completado +
+
-
+ {/* Progress Bar */} +
-
- {STEPS.map((step, index) => ( -
-
- {step.title} -
-
- {step.description} -
-
- ))} + {/* Mobile Step Indicators - Horizontal scroll on small screens */} +
+
+ {STEPS.map((step, index) => { + const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex; + const isCurrent = index === currentStepIndex; + + return ( +
+
+ {isCompleted ? ( +
+ + + +
+ ) : isCurrent ? ( +
+ {index + 1} +
+ ) : ( +
+ {index + 1} +
+ )} +
+
+ {step.title} +
+
+ ); + })} +
-
+ + {/* Desktop Step Indicators */} +
+ {STEPS.map((step, index) => { + const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex; + const isCurrent = index === currentStepIndex; + + return ( +
+
+ {isCompleted ? ( +
+ + + +
+ ) : isCurrent ? ( +
+ {index + 1} +
+ ) : ( +
+ {index + 1} +
+ )} +
+
+ {step.title} +
+
+ {step.description} +
+
+ ); + })} +
+ {/* Step Content */} -
-
-

- {currentStep.title} -

-

- {currentStep.description} -

-
- - {}} // No-op - steps must use onComplete instead - onPrevious={handlePrevious} - onComplete={handleStepComplete} - isFirstStep={currentStepIndex === 0} - isLastStep={currentStepIndex === STEPS.length - 1} - /> -
- - {/* Navigation */} -
- + + +
+
+
+ {currentStepIndex + 1} +
+
+
+

+ {currentStep.title} +

+

+ {currentStep.description} +

+
+
+
-
- Puedes pausar y reanudar este proceso en cualquier momento -
- - {/* No skip button - all steps are required */} -
-
+ + {}} // No-op - steps must use onComplete instead + onPrevious={() => {}} // No-op - users cannot go back once they've moved forward + onComplete={handleStepComplete} + isFirstStep={currentStepIndex === 0} + isLastStep={currentStepIndex === STEPS.length - 1} + /> + +
); }; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx index d89d5f3b..c90339aa 100644 --- a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useCallback } from 'react'; import { Button } from '../../../ui/Button'; import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training'; @@ -32,50 +32,59 @@ export const MLTrainingStep: React.FC = ({ const currentTenant = useCurrentTenant(); const createTrainingJob = useCreateTrainingJob(); - // WebSocket for real-time training progress - const trainingWebSocket = useTrainingWebSocket( + // Memoized WebSocket callbacks to prevent reconnections + const handleProgress = useCallback((data: any) => { + setTrainingProgress({ + stage: 'training', + progress: data.data?.progress || 0, + message: data.data?.message || 'Entrenando modelo...', + currentStep: data.data?.current_step, + estimatedTimeRemaining: data.data?.estimated_time_remaining + }); + }, []); + + const handleCompleted = useCallback((_data: any) => { + setTrainingProgress({ + stage: 'completed', + progress: 100, + message: 'Entrenamiento completado exitosamente' + }); + setIsTraining(false); + + setTimeout(() => { + onComplete({ + jobId: jobId, + success: true, + message: 'Modelo entrenado correctamente' + }); + }, 2000); + }, [onComplete, jobId]); + + const handleError = useCallback((data: any) => { + setError(data.data?.error || data.error || 'Error durante el entrenamiento'); + setIsTraining(false); + setTrainingProgress(null); + }, []); + + const handleStarted = useCallback((_data: any) => { + setTrainingProgress({ + stage: 'starting', + progress: 5, + message: 'Iniciando entrenamiento del modelo...' + }); + }, []); + + // WebSocket for real-time training progress - only connect when we have a jobId + const { isConnected, connectionError } = useTrainingWebSocket( currentTenant?.id || '', jobId || '', undefined, // token will be handled by the service - { - onProgress: (data) => { - setTrainingProgress({ - stage: 'training', - progress: data.progress?.percentage || 0, - message: data.message || 'Entrenando modelo...', - currentStep: data.progress?.current_step, - estimatedTimeRemaining: data.progress?.estimated_time_remaining - }); - }, - onCompleted: (data) => { - setTrainingProgress({ - stage: 'completed', - progress: 100, - message: 'Entrenamiento completado exitosamente' - }); - setIsTraining(false); - - setTimeout(() => { - onComplete({ - jobId: jobId, - success: true, - message: 'Modelo entrenado correctamente' - }); - }, 2000); - }, - onError: (data) => { - setError(data.error || 'Error durante el entrenamiento'); - setIsTraining(false); - setTrainingProgress(null); - }, - onStarted: (data) => { - setTrainingProgress({ - stage: 'starting', - progress: 5, - message: 'Iniciando entrenamiento del modelo...' - }); - } - } + jobId ? { + onProgress: handleProgress, + onCompleted: handleCompleted, + onError: handleError, + onStarted: handleStarted + } : undefined ); const handleStartTraining = async () => { @@ -201,9 +210,16 @@ export const MLTrainingStep: React.FC = ({
{trainingProgress.currentStep || 'Procesando...'} - {trainingProgress.estimatedTimeRemaining && ( - Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)} - )} +
+ {jobId && ( + + {isConnected ? '🟢 Conectado' : '🔴 Desconectado'} + + )} + {trainingProgress.estimatedTimeRemaining && ( + Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)} + )} +
)} @@ -224,9 +240,9 @@ export const MLTrainingStep: React.FC = ({
- {error && ( + {(error || connectionError) && (
-

{error}

+

{error || connectionError}

)} diff --git a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx index 3892e229..9b8bd0f7 100644 --- a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx @@ -24,18 +24,28 @@ interface ProgressState { interface InventoryItem { suggestion_id: string; + original_name: string; suggested_name: string; + product_type: string; category: string; unit_of_measure: string; - selected: boolean; - stock_quantity: number; - expiration_days: number; - cost_per_unit: number; confidence_score: number; + estimated_shelf_life_days?: number; requires_refrigeration: boolean; requires_freezing: boolean; is_seasonal: boolean; + suggested_supplier?: string; notes?: string; + sales_data?: { + total_quantity: number; + average_daily_sales: number; + peak_day: string; + frequency: number; + }; + // UI-specific fields + selected: boolean; + stock_quantity: number; + cost_per_unit: number; } export const UploadSalesDataStep: React.FC = ({ @@ -131,15 +141,9 @@ export const UploadSalesDataStep: React.FC = ({ setProgressState({ stage: 'analyzing', progress: 25, message: 'Analizando productos de ventas...' }); try { - // Extract product data from validation result - const products = validationResult.product_summary?.map((product: any) => ({ - product_name: product.name, - sales_volume: product.total_quantity, - sales_data: { - total_quantity: product.total_quantity, - average_daily_sales: product.average_daily_sales, - frequency: product.frequency - } + // Extract product data from validation result - use the exact backend structure + const products = validationResult.product_list?.map((productName: string) => ({ + product_name: productName })) || []; if (products.length === 0) { @@ -158,7 +162,7 @@ export const UploadSalesDataStep: React.FC = ({ setProgressState({ stage: 'preparing', progress: 75, message: 'Preparando sugerencias de inventario...' }); - // Convert API response to InventoryItem format + // Convert API response to InventoryItem format - use exact backend structure plus UI fields const items: InventoryItem[] = suggestions.map(suggestion => { // Calculate default stock quantity based on sales data const defaultStock = Math.max( @@ -172,19 +176,25 @@ export const UploadSalesDataStep: React.FC = ({ 3.0; return { + // Exact backend fields suggestion_id: suggestion.suggestion_id, + original_name: suggestion.original_name, suggested_name: suggestion.suggested_name, + product_type: suggestion.product_type, category: suggestion.category, unit_of_measure: suggestion.unit_of_measure, - selected: suggestion.confidence_score > 0.7, // Auto-select high confidence items - stock_quantity: defaultStock, - expiration_days: suggestion.estimated_shelf_life_days || 30, - cost_per_unit: estimatedCost, confidence_score: suggestion.confidence_score, + estimated_shelf_life_days: suggestion.estimated_shelf_life_days, requires_refrigeration: suggestion.requires_refrigeration, requires_freezing: suggestion.requires_freezing, is_seasonal: suggestion.is_seasonal, - notes: suggestion.notes + suggested_supplier: suggestion.suggested_supplier, + notes: suggestion.notes, + sales_data: suggestion.sales_data, + // UI-specific fields + selected: suggestion.confidence_score > 0.7, // Auto-select high confidence items + stock_quantity: defaultStock, + cost_per_unit: estimatedCost }; }); @@ -241,18 +251,31 @@ export const UploadSalesDataStep: React.FC = ({ const createdIngredients = []; for (const item of selectedItems) { + // Ensure reorder_point > minimum_stock_level as required by backend validation + const minimumStock = Math.max(1, Math.ceil(item.stock_quantity * 0.2)); + const calculatedReorderPoint = Math.ceil(item.stock_quantity * 0.3); + const reorderPoint = Math.max(minimumStock + 2, calculatedReorderPoint, minimumStock + 1); + + console.log(`📊 Inventory validation for "${item.suggested_name}":`, { + stockQuantity: item.stock_quantity, + minimumStock, + calculatedReorderPoint, + finalReorderPoint: reorderPoint, + isValid: reorderPoint > minimumStock + }); + const ingredientData = { name: item.suggested_name, category: item.category, unit_of_measure: item.unit_of_measure, - minimum_stock_level: Math.ceil(item.stock_quantity * 0.2), - maximum_stock_level: item.stock_quantity * 2, - reorder_point: Math.ceil(item.stock_quantity * 0.3), - shelf_life_days: item.expiration_days, + low_stock_threshold: minimumStock, + max_stock_level: item.stock_quantity * 2, + reorder_point: reorderPoint, + shelf_life_days: item.estimated_shelf_life_days || 30, requires_refrigeration: item.requires_refrigeration, requires_freezing: item.requires_freezing, is_seasonal: item.is_seasonal, - cost_per_unit: item.cost_per_unit, + average_cost: item.cost_per_unit, notes: item.notes || `Creado durante onboarding - Confianza: ${Math.round(item.confidence_score * 100)}%` }; @@ -338,12 +361,12 @@ export const UploadSalesDataStep: React.FC = ({ {/* Summary */}
-
-
-

+

+
+

{selectedCount} de {inventoryItems.length} artículos seleccionados

-

+

Los artículos con alta confianza están preseleccionados

@@ -351,6 +374,7 @@ export const UploadSalesDataStep: React.FC = ({ variant="outline" size="sm" onClick={handleSelectAll} + className="w-full sm:w-auto" > {allSelected ? 'Deseleccionar Todos' : 'Seleccionar Todos'} @@ -361,7 +385,7 @@ export const UploadSalesDataStep: React.FC = ({
{inventoryItems.map((item) => (
= ({ handleToggleSelection(item.id)} + onChange={() => handleToggleSelection(item.suggestion_id)} className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]" />
@@ -381,7 +405,7 @@ export const UploadSalesDataStep: React.FC = ({

- {item.name} + {item.suggested_name}

{item.category} • Unidad: {item.unit_of_measure} @@ -395,18 +419,28 @@ export const UploadSalesDataStep: React.FC = ({ Requiere refrigeración )} + {item.requires_freezing && ( + + Requiere congelación + + )} + {item.is_seasonal && ( + + Producto estacional + + )}

{item.selected && ( -
+
handleUpdateItem( - item.id, + item.suggestion_id, 'stock_quantity', Number(e.target.value) )} @@ -419,7 +453,7 @@ export const UploadSalesDataStep: React.FC = ({ step="0.01" value={item.cost_per_unit.toString()} onChange={(e) => handleUpdateItem( - item.id, + item.suggestion_id, 'cost_per_unit', Number(e.target.value) )} @@ -429,13 +463,14 @@ export const UploadSalesDataStep: React.FC = ({ label="Días de Caducidad" type="number" min="1" - value={item.expiration_days.toString()} + value={(item.estimated_shelf_life_days || 30).toString()} onChange={(e) => handleUpdateItem( - item.id, - 'expiration_days', + item.suggestion_id, + 'estimated_shelf_life_days', Number(e.target.value) )} size="sm" + className="sm:col-span-2 lg:col-span-1" />
)} @@ -452,10 +487,11 @@ export const UploadSalesDataStep: React.FC = ({ )} {/* Actions */} -
+
@@ -466,8 +502,14 @@ export const UploadSalesDataStep: React.FC = ({ loadingText="Creando Inventario..." size="lg" disabled={selectedCount === 0} + className="order-1 sm:order-2 w-full sm:w-auto" > - Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario + + Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario + + + Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} +
@@ -574,7 +616,9 @@ export const UploadSalesDataStep: React.FC = ({

Warnings:

    {validationResult.warnings.map((warning, index) => ( -
  • {warning}
  • +
  • + {typeof warning === 'string' ? warning : JSON.stringify(warning)} +
  • ))}
@@ -591,23 +635,26 @@ export const UploadSalesDataStep: React.FC = ({ )} {/* Actions */} -
+
-
+
{selectedFile && !validationResult && ( )} @@ -615,8 +662,9 @@ export const UploadSalesDataStep: React.FC = ({ )}