Add onboardin steps improvements

This commit is contained in:
Urtzi Alfaro
2025-08-11 07:01:08 +02:00
parent c721575cd3
commit c4d4aeb449
16 changed files with 2015 additions and 103 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader } from 'lucide-react';
import toast from 'react-hot-toast';
@@ -9,10 +9,13 @@ import {
useTraining,
useData,
useTrainingWebSocket,
useOnboarding,
TenantCreate,
TrainingJobRequest
} from '../../api';
import { OnboardingRouter } from '../../utils/onboardingRouter';
interface OnboardingPageProps {
user: any;
onComplete: () => void;
@@ -47,6 +50,15 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking
const {
progress,
isLoading: onboardingLoading,
completeStep,
refreshProgress
} = useOnboarding();
const [bakeryData, setBakeryData] = useState<BakeryData>({
name: '',
address: '',
@@ -56,9 +68,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
});
// Training progress state
const [tenantId, setTenantId] = useState<string>('');
const [tenantId, setTenantId] = useState<string>(() => {
// Initialize from localStorage
return localStorage.getItem('current_tenant_id') || '';
});
const [trainingJobId, setTrainingJobId] = useState<string>('');
const { createTenant, isLoading: tenantLoading } = useTenant();
const { createTenant, getUserTenants, isLoading: tenantLoading } = useTenant();
const { startTrainingJob } = useTraining({ disablePolling: true });
const { uploadSalesHistory, validateSalesData } = useData();
@@ -78,6 +93,60 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
estimatedTimeRemaining: 0
});
// Sales data validation state
const [validationStatus, setValidationStatus] = useState<{
status: 'idle' | 'validating' | 'valid' | 'invalid';
message?: string;
records?: number;
}>({
status: 'idle'
});
// Initialize current step from onboarding progress (only when not manually navigating)
useEffect(() => {
const initializeStepFromProgress = async () => {
if (progress && !manualNavigation.current) {
// Only initialize from progress if we haven't manually navigated
try {
const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
console.log('🎯 Initializing step from progress:', step);
setCurrentStep(step);
} catch (error) {
console.warn('Failed to get step from progress, using default:', error);
}
}
};
initializeStepFromProgress();
}, [progress]);
// Fetch tenant ID from backend if not available locally
useEffect(() => {
const fetchTenantIdFromBackend = async () => {
if (!tenantId && user) {
console.log('🏢 No tenant ID in localStorage, fetching from backend...');
try {
const userTenants = await getUserTenants();
console.log('📋 User tenants:', userTenants);
if (userTenants.length > 0) {
// Use the first (most recent) tenant for onboarding
const primaryTenant = userTenants[0];
console.log('✅ Found tenant from backend:', primaryTenant.id);
setTenantId(primaryTenant.id);
storeTenantId(primaryTenant.id);
} else {
console.log(' No tenants found for user - will be created in step 1');
}
} catch (error) {
console.warn('⚠️ Failed to fetch user tenants:', error);
// This is not a critical error - tenant will be created in step 1 if needed
}
}
};
fetchTenantIdFromBackend();
}, [tenantId, user, getUserTenants]);
// WebSocket connection for real-time training updates
const {
@@ -118,9 +187,19 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
currentStep: 'Entrenamiento completado',
estimatedTimeRemaining: 0
}));
// Mark training step as completed in onboarding API
completeStep('training_completed', {
training_completed_at: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId
}).catch(error => {
console.warn('Failed to mark training as completed in API:', error);
});
// Auto-advance to final step after 2 seconds
setTimeout(() => {
manualNavigation.current = true;
setCurrentStep(4);
}, 2000);
@@ -203,32 +282,238 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
};
const handleNext = () => {
if (validateCurrentStep()) {
if (currentStep === 2) {
// Always proceed to training step after CSV upload
startTraining();
} else {
setCurrentStep(prev => Math.min(prev + 1, steps.length));
const handleNext = async () => {
console.log(`🚀 HandleNext called for step ${currentStep}`);
if (!validateCurrentStep()) {
console.log('❌ Validation failed for current step');
return;
}
setIsLoading(true);
try {
if (currentStep === 1) {
console.log('📝 Processing step 1: Register bakery');
await createBakeryAndTenant();
console.log('✅ Step 1 completed successfully');
} else if (currentStep === 2) {
console.log('📂 Processing step 2: Upload sales data');
await uploadAndValidateSalesData();
console.log('✅ Step 2 completed successfully');
}
} catch (error) {
console.error('❌ Error in step:', error);
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
} finally {
setIsLoading(false);
console.log(`🔄 Loading state set to false, current step: ${currentStep}`);
}
};
const handlePrevious = () => {
manualNavigation.current = true;
setCurrentStep(prev => Math.max(prev - 1, 1));
};
// Create bakery and tenant for step 1
const createBakeryAndTenant = async () => {
try {
console.log('🏪 Creating bakery tenant...');
let currentTenantId = tenantId;
// Check if user already has a tenant
if (currentTenantId) {
console.log(' User already has tenant ID:', currentTenantId);
console.log('✅ Using existing tenant, skipping creation');
} else {
console.log('📝 Creating new tenant');
const tenantData: TenantCreate = {
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const newTenant = await createTenant(tenantData);
console.log('✅ Tenant created:', newTenant.id);
currentTenantId = newTenant.id;
setTenantId(newTenant.id);
storeTenantId(newTenant.id);
}
// Mark step as completed in onboarding API (non-blocking)
try {
await completeStep('bakery_registered', {
bakery_name: bakeryData.name,
bakery_address: bakeryData.address,
business_type: bakeryData.businessType,
tenant_id: currentTenantId, // Use the correct tenant ID
user_id: user?.id
});
console.log('✅ Step marked as completed');
} catch (stepError) {
console.warn('⚠️ Failed to mark step as completed, but continuing:', stepError);
// Don't throw here - step completion is not critical for UI flow
}
toast.success('Panadería registrada correctamente');
console.log('🎯 Advancing to step 2');
manualNavigation.current = true;
setCurrentStep(2);
} catch (error) {
console.error('❌ Error creating tenant:', error);
throw new Error('Error al registrar la panadería');
}
};
// Validate sales data without importing
const validateSalesFile = async () => {
console.log('🔍 validateSalesFile called');
console.log('tenantId:', tenantId);
console.log('bakeryData.csvFile:', bakeryData.csvFile);
if (!tenantId || !bakeryData.csvFile) {
console.log('❌ Missing tenantId or csvFile');
toast.error('Falta información del tenant o archivo');
return;
}
console.log('⏳ Setting validation status to validating');
setValidationStatus({ status: 'validating' });
try {
console.log('📤 Calling validateSalesData API');
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
console.log('📥 Validation result:', validationResult);
if (validationResult.is_valid) {
console.log('✅ Validation successful');
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
console.log('❌ Validation failed');
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
console.error('❌ Error validating sales data:', error);
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
};
// Upload and validate sales data for step 2
const uploadAndValidateSalesData = async () => {
if (!tenantId || !bakeryData.csvFile) {
throw new Error('Falta información del tenant o archivo');
}
try {
// If not already validated, validate first
if (validationStatus.status !== 'valid') {
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
if (!validationResult.is_valid) {
toast.error(`Error en validación: ${validationResult.message}`);
return;
}
}
// If validation passes, upload the data
const uploadResult = await uploadSalesHistory(tenantId, bakeryData.csvFile);
// Mark step as completed
await completeStep('sales_data_uploaded', {
file_name: bakeryData.csvFile.name,
file_size: bakeryData.csvFile.size,
records_imported: uploadResult.records_imported || 0,
validation_status: 'success',
tenant_id: tenantId,
user_id: user?.id
});
toast.success(`Datos importados correctamente (${uploadResult.records_imported} registros)`);
// Automatically start training after successful upload
await startTraining();
} catch (error: any) {
console.error('Error uploading sales data:', error);
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
throw new Error(message);
}
};
// Helper function to mark current step as completed
const markStepCompleted = async () => {
const stepMap: Record<number, string> = {
1: 'bakery_registered',
2: 'sales_data_uploaded',
3: 'training_completed',
4: 'dashboard_accessible'
};
const stepName = stepMap[currentStep];
if (stepName) {
const stepData: Record<string, any> = {
completed_at: new Date().toISOString(),
user_id: user?.id
};
// Add step-specific data
if (currentStep === 1) {
stepData.bakery_name = bakeryData.name;
stepData.bakery_address = bakeryData.address;
stepData.business_type = bakeryData.businessType;
stepData.products_count = bakeryData.products.length;
} else if (currentStep === 2) {
stepData.file_name = bakeryData.csvFile?.name;
stepData.file_size = bakeryData.csvFile?.size;
stepData.file_type = bakeryData.csvFile?.type;
stepData.has_historical_data = bakeryData.hasHistoricalData;
}
await completeStep(stepName, stepData);
// Note: Not calling refreshProgress() here to avoid step reset
toast.success(`✅ Paso ${currentStep} completado`);
}
};
const validateCurrentStep = (): boolean => {
console.log(`🔍 Validating step ${currentStep}`);
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
switch (currentStep) {
case 1:
if (!bakeryData.name.trim()) {
console.log('❌ Bakery name is empty');
toast.error('El nombre de la panadería es obligatorio');
return false;
}
if (!bakeryData.address.trim()) {
console.log('❌ Bakery address is empty');
toast.error('La dirección es obligatoria');
return false;
}
console.log('✅ Step 1 validation passed');
return true;
case 2:
if (!bakeryData.csvFile) {
@@ -252,6 +537,12 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
toast.error('El archivo es demasiado grande. Máximo 10MB');
return false;
}
// Check if validation was successful
if (validationStatus.status !== 'valid') {
toast.error('Por favor, valida el archivo antes de continuar');
return false;
}
return true;
default:
@@ -260,6 +551,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
};
const startTraining = async () => {
manualNavigation.current = true;
setCurrentStep(3);
setIsLoading(true);
@@ -270,48 +562,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
return;
}
// Create tenant first
const tenantData: TenantCreate = {
name: bakeryData.name,
address: bakeryData.address,
business_type: "bakery",
postal_code: "28010",
phone: "+34655334455",
coordinates: bakeryData.coordinates,
products: bakeryData.products,
has_historical_data: bakeryData.hasHistoricalData,
};
const tenant = await createTenant(tenantData);
setTenantId(tenant.id);
storeTenantId(tenant.id);
// Step 2: Validate and Upload CSV file if provided
if (bakeryData.csvFile) {
try {
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;
}
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;
}
if (!tenantId) {
toast.error('Error: No se ha registrado la panadería correctamente.');
return;
}
// Prepare training job request - always use uploaded data since CSV is required
@@ -323,7 +576,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
};
// Start training job using the proper API
const trainingJob = await startTrainingJob(tenant.id, trainingRequest);
const trainingJob = await startTrainingJob(tenantId, trainingRequest);
setTrainingJobId(trainingJob.job_id);
setTrainingProgress({
@@ -357,9 +610,24 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
// Start training process
await startTraining();
} else {
// Complete onboarding
toast.success('¡Configuración completada exitosamente!');
onComplete();
try {
// Mark final step as completed
await completeStep('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId,
final_step: true
});
// Complete onboarding
toast.success('¡Configuración completada exitosamente!');
onComplete();
} catch (error) {
console.warn('Failed to mark final step as completed:', error);
// Continue anyway for better UX
toast.success('¡Configuración completada exitosamente!');
onComplete();
}
}
};
@@ -380,6 +648,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
icon: '',
duration: 4000
});
manualNavigation.current = true;
setCurrentStep(4);
};
@@ -573,6 +842,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
csvFile: file,
hasHistoricalData: true
}));
// Reset validation status when new file is selected
setValidationStatus({ status: 'idle' });
toast.success(`Archivo ${file.name} seleccionado correctamente`);
}
}}
@@ -582,25 +853,130 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 p-4 bg-green-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-green-500 mr-2" />
<div>
<p className="text-sm font-medium text-green-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-green-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
<button
onClick={() => setBakeryData(prev => ({ ...prev, csvFile: undefined }))}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-500">
Debug: validationStatus = "{validationStatus.status}", tenantId = "{tenantId || 'not set'}"
</p>
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
@@ -748,6 +1124,18 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
};
// Show loading while onboarding data is being fetched
if (onboardingLoading && !progress) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 via-white to-blue-50">
<div className="text-center">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-primary-500" />
<p className="text-gray-600">Cargando progreso de configuración...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -759,6 +1147,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<p className="text-gray-600">
Configuremos tu panadería para obtener predicciones precisas de demanda
</p>
{/* Progress Summary */}
{progress && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
<div className="flex items-center justify-center space-x-4">
<div className="text-sm text-blue-700">
<span className="font-semibold">Progreso:</span> {Math.round(progress.completion_percentage)}%
</div>
<div className="text-sm text-blue-600">
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados)
</div>
</div>
</div>
)}
</div>
{/* Progress Indicator */}
@@ -790,7 +1192,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
<div className="mt-4 bg-gray-200 rounded-full h-2">
<div
className="bg-primary-500 h-2 rounded-full transition-all duration-500"
style={{ width: `${(currentStep / steps.length) * 100}%` }}
style={{
width: `${progress ? progress.completion_percentage : (currentStep / steps.length) * 100}%`
}}
/>
</div>
</div>
@@ -857,7 +1261,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
</>
) : trainingProgress.status === 'completed' ? (
<button
onClick={() => setCurrentStep(4)}
onClick={() => {
manualNavigation.current = true;
setCurrentStep(4);
}}
className="flex items-center px-6 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all hover:shadow-lg"
>
Continuar