diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6f732565..cfbd73b6 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -30,6 +30,23 @@ export { useApiHooks, } from './hooks'; +// Export WebSocket functionality +export { + WebSocketManager, + useWebSocket, + useTrainingWebSocket, + useForecastWebSocket, +} from './websocket'; + +// Export WebSocket types +export type { + WebSocketConfig, + WebSocketMessage, + WebSocketHandlers, + WebSocketStatus, + WebSocketMetrics, +} from './websocket'; + // Export types export * from './types'; diff --git a/frontend/src/api/websocket/hooks.ts b/frontend/src/api/websocket/hooks.ts index e305b343..f4b7e666 100644 --- a/frontend/src/api/websocket/hooks.ts +++ b/frontend/src/api/websocket/hooks.ts @@ -95,9 +95,10 @@ export const useWebSocket = (config: WebSocketConfig) => { }; // Hook for training job updates -export const useTrainingWebSocket = (tenantId: string) => { +export const useTrainingWebSocket = (jobId: string) => { + const config: WebSocketConfig = { - url: `ws://localhost:8000/api/v1/ws/training/${tenantId}`, + url: `ws://localhost:8002/api/v1/ws/tenants/{tenant_id}/training/jobs/${jobId}/live`, reconnect: true, }; diff --git a/frontend/src/pages/onboarding/OnboardingPage.tsx b/frontend/src/pages/onboarding/OnboardingPage.tsx index 6901bd30..787376ce 100644 --- a/frontend/src/pages/onboarding/OnboardingPage.tsx +++ b/frontend/src/pages/onboarding/OnboardingPage.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader } from 'lucide-react'; import toast from 'react-hot-toast'; import { @@ -7,7 +7,9 @@ import { useTraining, useData, useAuth, - TenantCreate + useTrainingWebSocket, + TenantCreate, + TrainingJobRequest } from '../../api'; interface OnboardingPageProps { @@ -25,6 +27,16 @@ interface BakeryData { csvFile?: File; } +interface TrainingProgress { + progress: number; + status: string; + currentStep: string; + productsCompleted: number; + productsTotal: number; + estimatedTimeRemaining: number; + error?: string; +} + const MADRID_PRODUCTS = [ 'Croissants', 'Pan de molde', 'Baguettes', 'Panecillos', 'Ensaimadas', 'Napolitanas', 'Magdalenas', 'Donuts', 'Palmeras', 'Café', @@ -42,20 +54,95 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => hasHistoricalData: false }); + // Training progress state + const [tenantId, setTenantId] = useState(''); + const [trainingJobId, setTrainingJobId] = useState(''); + const [trainingProgress, setTrainingProgress] = useState({ + progress: 0, + status: 'pending', + currentStep: 'Iniciando...', + productsCompleted: 0, + productsTotal: 0, + estimatedTimeRemaining: 0 + }); + const { createTenant, isLoading: tenantLoading } = useTenant(); - const { startTrainingJob } = useTraining(); + const { startTrainingJob, getTrainingJobStatus } = useTraining(); const { uploadSalesHistory, validateSalesData } = useData(); + + // WebSocket connection for real-time training updates + const { status, jobUpdates, connect, disconnect, isConnected } = useTrainingWebSocket(trainingJobId || 'pending'); const steps = [ { id: 1, title: 'Datos de Panadería', icon: Store }, { id: 2, title: 'Productos y Servicios', icon: Factory }, { id: 3, title: 'Datos Históricos', icon: Upload }, - { id: 4, title: 'Configuración Final', icon: Check } + { id: 4, title: 'Entrenamiento IA', icon: Brain }, + { id: 5, title: 'Configuración Final', icon: Check } ]; + // Handle WebSocket job updates + useEffect(() => { + if (jobUpdates.length > 0) { + const latestUpdate = jobUpdates[0]; + + // Update training progress based on WebSocket messages + if (latestUpdate.type === 'training_progress') { + setTrainingProgress(prev => ({ + ...prev, + progress: latestUpdate.progress || 0, + currentStep: latestUpdate.current_step || 'Procesando...', + productsCompleted: latestUpdate.products_completed || 0, + productsTotal: latestUpdate.products_total || prev.productsTotal, + estimatedTimeRemaining: latestUpdate.estimated_time_remaining || 0, + status: 'running' + })); + } else if (latestUpdate.type === 'training_completed') { + setTrainingProgress(prev => ({ + ...prev, + progress: 100, + status: 'completed', + currentStep: 'Entrenamiento completado', + estimatedTimeRemaining: 0 + })); + + // Auto-advance to final step after 2 seconds + setTimeout(() => { + setCurrentStep(5); + }, 2000); + + } else if (latestUpdate.type === 'training_failed' || latestUpdate.type === 'training_error') { + setTrainingProgress(prev => ({ + ...prev, + status: 'failed', + error: latestUpdate.error || 'Error en el entrenamiento', + currentStep: 'Error en el entrenamiento' + })); + } + } + }, [jobUpdates]); + + // Connect to WebSocket when training starts + useEffect(() => { + if (tenantId && trainingJobId && currentStep === 4) { + connect(); + } + + return () => { + if (isConnected) { + disconnect(); + } + }; + }, [tenantId, trainingJobId, currentStep, connect, disconnect, isConnected]); + const handleNext = () => { if (validateCurrentStep()) { - setCurrentStep(prev => Math.min(prev + 1, steps.length)); + if (currentStep === 3) { + // Always proceed to training step after CSV upload + startTraining(); + } else { + setCurrentStep(prev => Math.min(prev + 1, steps.length)); + } } }; @@ -82,27 +169,50 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => } return true; case 3: - if (bakeryData.hasHistoricalData && !bakeryData.csvFile) { - toast.error('Por favor, sube tu archivo CSV con datos históricos'); + if (!bakeryData.csvFile) { + toast.error('Por favor, selecciona un archivo con tus datos históricos'); return false; } + + // Validate file format + const fileName = bakeryData.csvFile.name.toLowerCase(); + const supportedFormats = ['.csv', '.xlsx', '.xls', '.json']; + const isValidFormat = supportedFormats.some(format => fileName.endsWith(format)); + + if (!isValidFormat) { + toast.error('Formato de archivo no soportado. Usa CSV, Excel (.xlsx, .xls) o JSON'); + return false; + } + + // Validate file size (10MB limit as per backend) + const maxSize = 10 * 1024 * 1024; + if (bakeryData.csvFile.size > maxSize) { + toast.error('El archivo es demasiado grande. Máximo 10MB'); + return false; + } + return true; default: return true; } }; - const handleComplete = async () => { - if (!validateCurrentStep()) return; - + const startTraining = async () => { + setCurrentStep(4); setIsLoading(true); try { - // Step 1: Create tenant using the API service + const token = localStorage.getItem('auth_token'); + if (!token) { + toast.error('Sesión expirada. Por favor, inicia sesión nuevamente.'); + return; + } + + // Create tenant first const tenantData: TenantCreate = { name: bakeryData.name, address: bakeryData.address, - business_type:"bakery", + business_type: "bakery", postal_code: "28010", phone: "+34655334455", coordinates: bakeryData.coordinates, @@ -110,124 +220,112 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => has_historical_data: bakeryData.hasHistoricalData, }; - const token = localStorage.getItem('auth_token'); - if (!token) { - toast.error('Sesión expirada. Por favor, inicia sesión nuevamente.'); - // Redirect to login or handle authentication error - return; - } - - const newTenant = await createTenant(tenantData); - const tenantId = newTenant.id; + const tenant = await createTenant(tenantData); + setTenantId(tenant.id); // Step 2: Validate and Upload CSV file if provided - if (bakeryData.hasHistoricalData && bakeryData.csvFile) { + if (bakeryData.csvFile) { try { - // Step 2.1: First validate the CSV data - toast.loading('Validando datos del archivo CSV...', { id: 'csv-validation' }); - - // Check validation result - const validationResult = await validateSalesData(tenantId, bakeryData.csvFile); - - // Check validation result using the correct field names - if (!validationResult.is_valid) { // ← Changed from .success to .is_valid - // Validation failed - show errors but let user decide - const errorMessages = validationResult.errors?.slice(0, 3).map(e => e.message || 'Unknown error').join(', ') || 'Errores de validación'; - const hasMoreErrors = validationResult.errors && validationResult.errors.length > 3; - - toast.error( - `Errores en el archivo CSV: ${errorMessages}${hasMoreErrors ? '...' : ''}. Revisa la consola para más detalles.` - ); - console.error('CSV validation errors:', validationResult.errors); - console.warn('CSV validation warnings:', validationResult.warnings); - - // Don't proceed with upload if validation fails - throw new Error('Validación del CSV falló'); - } - - // Validation passed - show summary and proceed - if (validationResult.warnings && validationResult.warnings.length > 0) { - toast.warn(`CSV validado con ${validationResult.warnings.length} advertencias. Continuando con la subida...`); - console.warn('CSV validation warnings:', validationResult.warnings); - } else { - toast.success(`CSV validado correctamente. ${validationResult.valid_records} de ${validationResult.total_records} registros válidos.`); + const validationResult = await validateSalesData(tenant.id, bakeryData.csvFile); + if (!validationResult.is_valid) { + toast.error(`Error en los datos: ${validationResult.message}`); + setTrainingProgress(prev => ({ + ...prev, + status: 'failed', + error: 'Error en la validación de datos históricos' + })); + return; } - // Step 2.2: Now upload the validated CSV - toast.loading('Subiendo datos históricos...', { id: 'csv-upload' }); - - const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile, { - source: 'onboarding_upload', - validate_only: false - }); - - toast.dismiss('csv-upload'); - - // Check upload result - if (uploadResult.success) { - toast.success( - `¡Datos históricos subidos exitosamente! ${uploadResult.records_created} de ${uploadResult.records_processed} registros procesados.` - ); - - // Show additional info if some records failed - if (uploadResult.records_failed > 0) { - toast.warn( - `${uploadResult.records_failed} registros fallaron durante la subida. Success rate: ${uploadResult.success_rate?.toFixed(1)}%` - ); - console.warn('Upload errors:', uploadResult.errors); - } - - // Log warnings for debugging - if (uploadResult.warnings && uploadResult.warnings.length > 0) { - console.info('Upload warnings:', uploadResult.warnings); - } - - try { - // Start training process (if you have a training service) - await startTrainingJob(tenantId); - toast.success('¡Entrenamiento del modelo iniciado!'); - } catch (trainingError) { - console.warn('Training start failed:', trainingError); - // Don't fail onboarding if training fails to start - } - - } else { - // Upload failed - throw new Error(`Upload failed: ${uploadResult.errors?.join(', ') || 'Unknown error'}`); - } - - } catch (uploadError) { - // Handle validation or upload error gracefully - console.error('CSV validation/upload error:', uploadError); - - const errorMessage = uploadError instanceof Error ? uploadError.message : 'Error desconocido'; - - if (errorMessage.includes('Validación')) { - toast.error('No se pudieron subir los datos históricos debido a errores de validación. La configuración se completó sin datos históricos.'); - } else { - toast.warn(`Error al procesar archivo CSV: ${errorMessage}. La configuración se completó sin datos históricos.`); - } + await uploadSalesHistory(tenant.id, bakeryData.csvFile); + toast.success('Datos históricos validados y subidos correctamente'); + } catch (error) { + console.error('CSV validation/upload error:', error); + toast.error('Error al procesar los datos históricos'); + setTrainingProgress(prev => ({ + ...prev, + status: 'failed', + error: 'Error al procesar los datos históricos' + })); + return; } } + + // Prepare training job request - always use uploaded data since CSV is required + const trainingRequest: TrainingJobRequest = { + include_weather: true, + include_traffic: false, + min_data_points: 30, + use_default_data: false // Always false since CSV upload is mandatory + }; + + // Start training job using the proper API + const trainingJob = await startTrainingJob(tenant.id, trainingRequest); + setTrainingJobId(trainingJob.job_id); - toast.success('¡Configuración completada exitosamente!'); - onComplete(); - + setTrainingProgress({ + progress: 0, + status: 'running', + currentStep: 'Iniciando entrenamiento...', + productsCompleted: 0, + productsTotal: bakeryData.products.length, + estimatedTimeRemaining: 600 // 10 minutes + }); + + toast.success('Entrenamiento iniciado correctamente'); + } catch (error) { - console.error('Onboarding completion error:', error); - - // 🔧 ADD: Better error handling for auth issues - if (error instanceof Error && error.message.includes('401')) { - toast.error('Sesión expirada. Por favor, inicia sesión nuevamente.'); - } else { - toast.error('Error en la configuración. Inténtalo de nuevo.'); - } - + console.error('Training start error:', error); + toast.error('Error al iniciar el entrenamiento'); + setTrainingProgress(prev => ({ + ...prev, + status: 'failed', + error: 'Error al iniciar el entrenamiento' + })); } finally { setIsLoading(false); } }; + const handleComplete = async () => { + if (!validateCurrentStep()) return; + + if (currentStep < 4) { + // Start training process + await startTraining(); + } else { + // Complete onboarding + toast.success('¡Configuración completada exitosamente!'); + onComplete(); + } + }; + + const handleRetryTraining = async () => { + setTrainingProgress({ + progress: 0, + status: 'pending', + currentStep: 'Preparando reintento...', + productsCompleted: 0, + productsTotal: bakeryData.products.length, + estimatedTimeRemaining: 600 + }); + await startTraining(); + }; + + const handleSkipTraining = () => { + toast('Continuando sin entrenamiento. Podrás entrenar los modelos más tarde desde el dashboard.', { + icon: 'ℹ️', + duration: 4000 + }); + setCurrentStep(5); + }; + + const formatTimeRemaining = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${minutes}:${secs.toString().padStart(2, '0')}`; + }; + const renderStep = () => { switch (currentStep) { case 1: @@ -282,11 +380,10 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => : 'border-gray-300 hover:border-gray-400' }`} > - +
Panadería Individual
-
Una sola ubicación
+
Un solo punto de venta
- @@ -312,13 +409,13 @@ const OnboardingPage: React.FC = ({ user, onComplete }) =>

- ¿Qué productos vendes? + Productos y Servicios

- Selecciona los productos más comunes en tu panadería + Selecciona los productos que vendes regularmente. Esto nos ayudará a crear predicciones más precisas.

-
+
{MADRID_PRODUCTS.map((product) => ( ))}
- -
-

- Productos seleccionados: {bakeryData.products.length} -

-
+ + {bakeryData.products.length > 0 && ( +
+

+ ✅ {bakeryData.products.length} productos seleccionados +

+
+ )}
); @@ -356,96 +455,185 @@ const OnboardingPage: React.FC = ({ user, onComplete }) =>

- Datos Históricos de Ventas + Datos Históricos

- Para obtener mejores predicciones, puedes subir tus datos históricos de ventas + Para obtener predicciones precisas, necesitamos tus datos históricos de ventas. + Puedes subir archivos en varios formatos.

- -
-
- -
- - {bakeryData.hasHistoricalData && ( -
-
- - - {bakeryData.csvFile ? ( + +
+
+
+
+ ! +
+
+
+

+ Formatos soportados y estructura de datos +

+
+

Formatos aceptados:

+
-

- Archivo seleccionado: -

-

+

📊 Hojas de cálculo:

+
    +
  • .xlsx (Excel moderno)
  • +
  • .xls (Excel clásico)
  • +
+
+
+

📄 Datos estructurados:

+
    +
  • .csv (Valores separados por comas)
  • +
  • .json (Formato JSON)
  • +
+
+
+

Columnas requeridas (en cualquier idioma):

+
    +
  • Fecha: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)
  • +
  • Producto: producto, product, item, articulo, nombre
  • +
  • Cantidad: cantidad, quantity, cantidad_vendida, qty
  • +
+
+
+
+
+ +
+
+ +
+ + { + const file = e.target.files?.[0]; + if (file) { + // Validate file size (10MB limit) + const maxSize = 10 * 1024 * 1024; + if (file.size > maxSize) { + toast.error('El archivo es demasiado grande. Máximo 10MB.'); + return; + } + + setBakeryData(prev => ({ + ...prev, + csvFile: file, + hasHistoricalData: true + })); + toast.success(`Archivo ${file.name} seleccionado correctamente`); + } + }} + className="hidden" + /> +
+
+ + {bakeryData.csvFile ? ( +
+
+
+ +
+

{bakeryData.csvFile.name}

- -
- ) : ( -
-

- Sube tu archivo CSV con las ventas históricas +

+ {(bakeryData.csvFile.size / 1024).toFixed(1)} KB • {bakeryData.csvFile.type || 'Archivo de datos'}

- { - const file = e.target.files?.[0]; - if (file) { - setBakeryData(prev => ({ ...prev, csvFile: file })); - } - }} - className="hidden" - id="csv-upload" - /> -
- )} +
+
- -
-

Formato esperado del CSV:

-

Fecha, Producto, Cantidad

-

2024-01-01, Croissants, 45

-

2024-01-01, Pan de molde, 12

+
+ ) : ( +
+
+ +

+ Archivo requerido: Selecciona un archivo con tus datos históricos de ventas +

)} +
- {!bakeryData.hasHistoricalData && ( -
-

- No te preocupes, PanIA puede empezar a funcionar sin datos históricos. - Las predicciones mejorarán automáticamente conforme uses el sistema. -

+ {/* Sample formats examples */} +
+
+ Ejemplos de formato: +
+ +
+ {/* CSV Example */} +
+
+ 📄 CSV +
+
+
fecha,producto,cantidad
+
2024-01-15,Croissants,45
+
2024-01-15,Pan de molde,32
+
2024-01-16,Baguettes,28
+
- )} + + {/* Excel Example */} +
+
+ 📊 Excel +
+
+ + + + + + + + + + + + +
FechaProductoCantidad
15/01/2024Croissants45
15/01/2024Pan molde32
+
+
+
+ + {/* JSON Example */} +
+
+ 🔧 JSON +
+
+
[
+
{"{"}"fecha": "2024-01-15", "producto": "Croissants", "cantidad": 45{"}"},
+
{"{"}"fecha": "2024-01-15", "producto": "Pan de molde", "cantidad": 32{"}"}
+
]
+
+
@@ -455,47 +643,176 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => return (
-
- +
+
-

- ¡Todo listo para comenzar! +

+ 🧠 Entrenando tu modelo de predicción

-

- Revisa los datos de tu panadería antes de continuar +

+ Estamos procesando tus datos históricos para crear predicciones personalizadas

-
-
- Panadería: -

{bakeryData.name}

+ {/* WebSocket Connection Status */} + {tenantId && trainingJobId && ( +
+
+ {isConnected ? 'Conectado a actualizaciones en tiempo real' : 'Reconectando...'} +
+ )} +
+
+ {trainingProgress.currentStep} + + {trainingProgress.progress}% completado +
-
- Dirección: -

{bakeryData.address}

+
+
+
+ + {trainingProgress.productsTotal > 0 && ( +
+ + 📦 Productos: {trainingProgress.productsCompleted}/{trainingProgress.productsTotal} + + {trainingProgress.estimatedTimeRemaining > 0 && ( + + + {formatTimeRemaining(trainingProgress.estimatedTimeRemaining)} restante + + )} +
+ )} +
+ + {/* Training Status */} +
+ {trainingProgress.status === 'running' && ( +
+ +
+
Entrenamiento en progreso
+
+ Tu modelo está aprendiendo de los patrones históricos de ventas +
+
+
+ )} + + {trainingProgress.status === 'completed' && ( +
+ +
+
¡Entrenamiento completado!
+
+ Tu modelo está listo para generar predicciones precisas +
+
+
+ )} + + {trainingProgress.status === 'failed' && ( +
+
+ +
+
Error en el entrenamiento
+
+ {trainingProgress.error || 'Ha ocurrido un error durante el entrenamiento'} +
+
+
+ +
+ + +
+
+ )} +
+ + {/* Educational Content */} +
+
+
¿Qué está pasando?
+
+ Nuestro sistema está analizando patrones estacionales, tendencias de demanda y factores externos para crear un modelo personalizado para tu panadería. +
-
- Tipo de negocio: -

- {bakeryData.businessType === 'individual' ? 'Panadería Individual' : 'Obrador Central'} -

+
+
Beneficios esperados
+
+ Predicciones de demanda precisas, reducción de desperdicio, optimización de stock y mejor planificación de producción. +
- -
- Productos: -

{bakeryData.products.join(', ')}

-
- -
- Datos históricos: -

- {bakeryData.hasHistoricalData ? `Sí (${bakeryData.csvFile?.name})` : 'No'} -

+
+
+ ); + + case 5: + return ( +
+
+ +
+ +
+

+ ¡Configuración Completada! 🎉 +

+

+ Tu panadería está lista para usar PanIA. Comenzarás a recibir predicciones precisas de demanda. +

+
+ +
+

Resumen de configuración:

+
+
+ Panadería: + {bakeryData.name} +
+
+ Productos: + {bakeryData.products.length} seleccionados +
+
+ Datos históricos: + + ✅ {bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} subido + +
+
+ Modelo IA: + ✅ Entrenado +
+ +
+

+ 💡 Próximo paso: Explora tu dashboard para ver las primeras predicciones y configurar alertas personalizadas. +

+
); @@ -505,34 +822,37 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => }; return ( -
-
+
+
{/* Header */}
-
- 🥖 -
-

- Configuración inicial +

+ Configuración de PanIA

- Vamos a configurar PanIA para tu panadería + Configuremos tu panadería para obtener predicciones precisas de demanda

- {/* Progress Steps */} + {/* Progress Indicator */}
-
- {steps.map((step) => ( +
+ {steps.map((step, index) => (
= step.id + className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-300 ${ + currentStep > step.id + ? 'bg-green-500 border-green-500 text-white' + : currentStep === step.id ? 'bg-primary-500 border-primary-500 text-white' : 'border-gray-300 text-gray-500' }`} > - + {currentStep > step.id ? ( + + ) : ( + + )}
{step.title} @@ -564,24 +884,111 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => Anterior - {currentStep < steps.length ? ( + {/* Dynamic Next/Complete Button */} + {currentStep < 4 ? ( + ) : currentStep === 4 ? ( + // Training step - show different buttons based on status +
+ {trainingProgress.status === 'failed' ? ( + <> + + + + ) : trainingProgress.status === 'completed' ? ( + + ) : ( + // Training in progress - show status + + )} +
) : ( + // Final step - Complete button )}
+ + {/* Help Section */} +
+
+

¿Necesitas ayuda?

+
+
+
+ 📧 soporte@pania.es +
+
+
+ 📞 +34 900 123 456 +
+
+
+ 💬 Chat en vivo +
+
+
+
);