Improve the UX and UI of the onboarding steps
This commit is contained in:
372
frontend/src/components/SimplifiedTrainingProgress.tsx
Normal file
372
frontend/src/components/SimplifiedTrainingProgress.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Sparkles, CheckCircle, Clock, ArrowRight, Coffee,
|
||||||
|
TrendingUp, Target, Loader, AlertTriangle, Mail,
|
||||||
|
ChevronDown, ChevronUp, HelpCircle, ExternalLink
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface SimplifiedTrainingProgressProps {
|
||||||
|
progress: {
|
||||||
|
progress: number;
|
||||||
|
status: string;
|
||||||
|
currentStep: string;
|
||||||
|
productsCompleted: number;
|
||||||
|
productsTotal: number;
|
||||||
|
estimatedTimeRemaining: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
onTimeout?: () => void;
|
||||||
|
onBackgroundMode?: () => void;
|
||||||
|
onEmailNotification?: (email: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proceso simplificado de entrenamiento en 3 etapas
|
||||||
|
const TRAINING_STAGES = [
|
||||||
|
{
|
||||||
|
id: 'preparing',
|
||||||
|
title: 'Preparando Tus Datos',
|
||||||
|
description: 'Estamos organizando tu historial de ventas para encontrar patrones',
|
||||||
|
userFriendly: 'Piensa en esto como ordenar tus recibos para entender tus días de mayor venta',
|
||||||
|
progressRange: [0, 30],
|
||||||
|
icon: Coffee,
|
||||||
|
color: 'blue',
|
||||||
|
celebration: '📊 ¡Los datos están listos!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'learning',
|
||||||
|
title: 'Construyendo Tu Modelo',
|
||||||
|
description: 'Tu IA está aprendiendo de tus patrones de ventas',
|
||||||
|
userFriendly: 'Como enseñar a un asistente inteligente a reconocer cuándo vendes más pan',
|
||||||
|
progressRange: [30, 80],
|
||||||
|
icon: Sparkles,
|
||||||
|
color: 'purple',
|
||||||
|
celebration: '🧠 ¡Tu IA se está volviendo inteligente!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finalizing',
|
||||||
|
title: 'Casi Listo',
|
||||||
|
description: 'Ajustando las predicciones para tu panadería',
|
||||||
|
userFriendly: 'Nos aseguramos de que las predicciones funcionen perfectamente para tu negocio específico',
|
||||||
|
progressRange: [80, 100],
|
||||||
|
icon: Target,
|
||||||
|
color: 'green',
|
||||||
|
celebration: '🎉 ¡Tus predicciones están listas!'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const BENEFITS_PREVIEW = [
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
title: 'Pronósticos Inteligentes',
|
||||||
|
description: 'Saber exactamente cuánto hornear cada día',
|
||||||
|
example: 'Nunca te quedes sin croissants los domingos ocupados'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Target,
|
||||||
|
title: 'Reducir Desperdicios',
|
||||||
|
description: 'Deja de hacer demasiado de productos que se venden poco',
|
||||||
|
example: 'Ahorra dinero horneando las cantidades correctas'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Sparkles,
|
||||||
|
title: 'Detectar Tendencias',
|
||||||
|
description: 'Ve qué productos se están volviendo populares',
|
||||||
|
example: 'Nota cuando los clientes empiezan a amar tu nueva receta'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SimplifiedTrainingProgress({
|
||||||
|
progress,
|
||||||
|
onTimeout,
|
||||||
|
onBackgroundMode,
|
||||||
|
onEmailNotification
|
||||||
|
}: SimplifiedTrainingProgressProps) {
|
||||||
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [showTimeoutOptions, setShowTimeoutOptions] = useState(false);
|
||||||
|
const [emailForNotification, setEmailForNotification] = useState('');
|
||||||
|
const [celebratingStage, setCelebratingStage] = useState<string | null>(null);
|
||||||
|
const [startTime] = useState(Date.now());
|
||||||
|
|
||||||
|
// Show timeout options after 7 minutes for better UX
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (progress.status === 'running' && progress.progress < 90) {
|
||||||
|
setShowTimeoutOptions(true);
|
||||||
|
}
|
||||||
|
}, 420000); // 7 minutes
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [progress.status, progress.progress]);
|
||||||
|
|
||||||
|
// Celebrate stage completions
|
||||||
|
useEffect(() => {
|
||||||
|
TRAINING_STAGES.forEach(stage => {
|
||||||
|
if (progress.progress >= stage.progressRange[1] &&
|
||||||
|
celebratingStage !== stage.id &&
|
||||||
|
progress.progress > 0) {
|
||||||
|
setCelebratingStage(stage.id);
|
||||||
|
setTimeout(() => setCelebratingStage(null), 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [progress.progress, celebratingStage]);
|
||||||
|
|
||||||
|
const getCurrentStage = () => {
|
||||||
|
return TRAINING_STAGES.find(stage =>
|
||||||
|
progress.progress >= stage.progressRange[0] &&
|
||||||
|
progress.progress < stage.progressRange[1]
|
||||||
|
) || TRAINING_STAGES[TRAINING_STAGES.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeRemaining = (timeValue: number): string => {
|
||||||
|
if (!timeValue || timeValue <= 0) return 'Casi terminado';
|
||||||
|
|
||||||
|
// Manejar tanto segundos como minutos del backend
|
||||||
|
// Si el valor es muy grande, probablemente sean segundos; si es pequeño, probablemente sean minutos
|
||||||
|
const minutes = timeValue > 120 ? Math.floor(timeValue / 60) : Math.floor(timeValue);
|
||||||
|
|
||||||
|
if (minutes <= 1) return 'Menos de un minuto';
|
||||||
|
if (minutes < 60) return `Aproximadamente ${minutes} minutos restantes`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return hours === 1
|
||||||
|
? `Aproximadamente 1 hora ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim()
|
||||||
|
: `Aproximadamente ${hours} horas ${remainingMinutes > 0 ? `${remainingMinutes} minutos` : ''} restantes`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailNotification = () => {
|
||||||
|
if (emailForNotification && onEmailNotification) {
|
||||||
|
onEmailNotification(emailForNotification);
|
||||||
|
setShowTimeoutOptions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentStage = getCurrentStage();
|
||||||
|
|
||||||
|
// Error State
|
||||||
|
if (progress.status === 'failed' || progress.error) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto text-center">
|
||||||
|
<div className="bg-white rounded-3xl shadow-lg p-8 mb-6">
|
||||||
|
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-3">
|
||||||
|
Algo salió mal
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
No te preocupes - esto pasa a veces. Nuestro equipo ha sido notificado y lo arreglará rápidamente.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Puedes intentar iniciar el entrenamiento de nuevo, o contactar a nuestro equipo de soporte si esto sigue pasando.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-primary-500 text-white px-6 py-3 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
Intentar de Nuevo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Main Progress Card */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-lg overflow-hidden mb-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-50 to-secondary-50 p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-r from-primary-500 to-primary-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<currentStage.icon className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
{currentStage.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 mb-4">
|
||||||
|
{currentStage.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="flex justify-between text-sm text-gray-500 mb-2">
|
||||||
|
<span>Progreso</span>
|
||||||
|
<span>{Math.round(progress.progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-primary-500 to-primary-600 h-full transition-all duration-500 ease-out rounded-full"
|
||||||
|
style={{ width: `${progress.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-500">
|
||||||
|
<Clock className="w-4 h-4 inline mr-1" />
|
||||||
|
{formatTimeRemaining(progress.estimatedTimeRemaining)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stage Progress */}
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-center space-x-6 mb-6">
|
||||||
|
{TRAINING_STAGES.map((stage, index) => {
|
||||||
|
const isCompleted = progress.progress >= stage.progressRange[1];
|
||||||
|
const isCurrent = stage.id === currentStage.id;
|
||||||
|
const isCelebrating = celebratingStage === stage.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage.id} className="flex flex-col items-center relative">
|
||||||
|
{isCelebrating && (
|
||||||
|
<div className="absolute -top-8 bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full animate-bounce">
|
||||||
|
{stage.celebration}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-500 ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-green-500 text-white shadow-lg scale-110'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-primary-500 text-white animate-pulse shadow-lg'
|
||||||
|
: 'bg-gray-200 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
) : (
|
||||||
|
<stage.icon className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600 mt-2 text-center">
|
||||||
|
{stage.title.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
{index < TRAINING_STAGES.length - 1 && (
|
||||||
|
<ArrowRight className={`absolute top-5 -right-3 w-4 h-4 ${
|
||||||
|
isCompleted ? 'text-green-500' : 'text-gray-300'
|
||||||
|
}`} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Stage Explanation */}
|
||||||
|
<div className="bg-gray-50 rounded-xl p-4 text-center mb-4">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
{currentStage.userFriendly}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional Details */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDetails(!showDetails)}
|
||||||
|
className="flex items-center justify-center w-full text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="w-4 h-4 mr-1" />
|
||||||
|
{showDetails ? 'Ocultar' : 'Aprende más sobre qué está pasando'}
|
||||||
|
{showDetails ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 rounded-xl text-sm text-blue-800">
|
||||||
|
<p className="mb-2">
|
||||||
|
<strong>Detalles técnicos:</strong> Estamos usando aprendizaje automático para analizar tus patrones de ventas,
|
||||||
|
tendencias estacionales y relaciones entre productos para crear predicciones precisas de demanda.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="text-blue-600 hover:text-blue-700 inline-flex items-center"
|
||||||
|
>
|
||||||
|
Aprende más sobre nuestra tecnología de IA
|
||||||
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits Preview */}
|
||||||
|
<div className="bg-white rounded-3xl shadow-lg p-6 mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-4 text-center">
|
||||||
|
Lo que obtendrás cuando esto termine
|
||||||
|
</h3>
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
{BENEFITS_PREVIEW.map((benefit, index) => (
|
||||||
|
<div key={index} className="text-center p-4 rounded-xl bg-gradient-to-b from-gray-50 to-white border">
|
||||||
|
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<benefit.icon className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">{benefit.title}</h4>
|
||||||
|
<p className="text-gray-600 text-sm mb-2">{benefit.description}</p>
|
||||||
|
<p className="text-xs text-blue-600 italic">ej., {benefit.example}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeout Options */}
|
||||||
|
{showTimeoutOptions && (
|
||||||
|
<div className="bg-yellow-50 border border-yellow-200 rounded-3xl p-6">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<Clock className="w-8 h-8 text-yellow-600 mx-auto mb-2" />
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">¿Tomando más tiempo del esperado?</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
¡No te preocupes! Tienes algunas opciones mientras terminamos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{/* Continue in Background */}
|
||||||
|
<div className="bg-white rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">Continuar en segundo plano</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Explora una vista previa de tu panel de control mientras continúa el entrenamiento.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBackgroundMode}
|
||||||
|
className="w-full bg-primary-500 text-white py-2 px-4 rounded-lg hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
Vista Previa del Panel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Notification */}
|
||||||
|
<div className="bg-white rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-2">Recibir notificación</h4>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Te enviaremos un correo cuando tu modelo esté listo.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@correo.com"
|
||||||
|
value={emailForNotification}
|
||||||
|
onChange={(e) => setEmailForNotification(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleEmailNotification}
|
||||||
|
disabled={!emailForNotification}
|
||||||
|
className="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 inline mr-1" />
|
||||||
|
Notificarme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTimeoutOptions(false)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
Esperaré aquí
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } 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 { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain, Clock, CheckCircle, AlertTriangle, Loader, TrendingUp } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
import EnhancedTrainingProgress from '../../components/EnhancedTrainingProgress';
|
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useTenant,
|
useTenant,
|
||||||
@@ -109,10 +109,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
// Only initialize from progress if we haven't manually navigated
|
// Only initialize from progress if we haven't manually navigated
|
||||||
try {
|
try {
|
||||||
const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
|
const { step } = await OnboardingRouter.getOnboardingStepFromProgress();
|
||||||
console.log('🎯 Initializing step from progress:', step);
|
|
||||||
setCurrentStep(step);
|
setCurrentStep(step);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to get step from progress, using default:', error);
|
// Silently use default step on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -124,22 +123,16 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchTenantIdFromBackend = async () => {
|
const fetchTenantIdFromBackend = async () => {
|
||||||
if (!tenantId && user) {
|
if (!tenantId && user) {
|
||||||
console.log('🏢 No tenant ID in localStorage, fetching from backend...');
|
|
||||||
try {
|
try {
|
||||||
const userTenants = await getUserTenants();
|
const userTenants = await getUserTenants();
|
||||||
console.log('📋 User tenants:', userTenants);
|
|
||||||
|
|
||||||
if (userTenants.length > 0) {
|
if (userTenants.length > 0) {
|
||||||
// Use the first (most recent) tenant for onboarding
|
// Use the first (most recent) tenant for onboarding
|
||||||
const primaryTenant = userTenants[0];
|
const primaryTenant = userTenants[0];
|
||||||
console.log('✅ Found tenant from backend:', primaryTenant.id);
|
|
||||||
setTenantId(primaryTenant.id);
|
setTenantId(primaryTenant.id);
|
||||||
storeTenantId(primaryTenant.id);
|
storeTenantId(primaryTenant.id);
|
||||||
} else {
|
|
||||||
console.log('ℹ️ No tenants found for user - will be created in step 1');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
// This is not a critical error - tenant will be created in step 1 if needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,14 +187,20 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
user_id: user?.id,
|
user_id: user?.id,
|
||||||
tenant_id: tenantId
|
tenant_id: tenantId
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.warn('Failed to mark training as completed in API:', error);
|
// Failed to mark training as completed in API
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show celebration and auto-advance to final step after 3 seconds
|
||||||
|
// Users can still manually navigate to dashboard immediately if they want
|
||||||
|
toast.success('🎉 Training completed! Your AI model is ready to use.', {
|
||||||
|
duration: 5000,
|
||||||
|
icon: '🤖'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-advance to final step after 2 seconds
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
manualNavigation.current = true;
|
manualNavigation.current = true;
|
||||||
setCurrentStep(4);
|
setCurrentStep(4);
|
||||||
}, 2000);
|
}, 3000);
|
||||||
|
|
||||||
} else if (messageType === 'failed' || messageType === 'training_failed' || messageType === 'training_error') {
|
} else if (messageType === 'failed' || messageType === 'training_failed' || messageType === 'training_error') {
|
||||||
setTrainingProgress(prev => ({
|
setTrainingProgress(prev => ({
|
||||||
@@ -276,17 +275,13 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
last_updated: new Date().toISOString()
|
last_updated: new Date().toISOString()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('✅ Tenant ID stored successfully:', tenantId);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to store tenant ID:', error);
|
// Handle storage error silently
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
console.log(`🚀 HandleNext called for step ${currentStep}`);
|
|
||||||
|
|
||||||
if (!validateCurrentStep()) {
|
if (!validateCurrentStep()) {
|
||||||
console.log('❌ Validation failed for current step');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,20 +289,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentStep === 1) {
|
if (currentStep === 1) {
|
||||||
console.log('📝 Processing step 1: Register bakery');
|
|
||||||
await createBakeryAndTenant();
|
await createBakeryAndTenant();
|
||||||
console.log('✅ Step 1 completed successfully');
|
|
||||||
} else if (currentStep === 2) {
|
} else if (currentStep === 2) {
|
||||||
console.log('📂 Processing step 2: Upload sales data');
|
|
||||||
await uploadAndValidateSalesData();
|
await uploadAndValidateSalesData();
|
||||||
console.log('✅ Step 2 completed successfully');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error in step:', error);
|
|
||||||
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
|
toast.error('Error al procesar el paso. Por favor, inténtalo de nuevo.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
console.log(`🔄 Loading state set to false, current step: ${currentStep}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -319,16 +308,10 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
// Create bakery and tenant for step 1
|
// Create bakery and tenant for step 1
|
||||||
const createBakeryAndTenant = async () => {
|
const createBakeryAndTenant = async () => {
|
||||||
try {
|
try {
|
||||||
console.log('🏪 Creating bakery tenant...');
|
|
||||||
|
|
||||||
let currentTenantId = tenantId;
|
let currentTenantId = tenantId;
|
||||||
|
|
||||||
// Check if user already has a tenant
|
// Check if user already has a tenant
|
||||||
if (currentTenantId) {
|
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 = {
|
const tenantData: TenantCreate = {
|
||||||
name: bakeryData.name,
|
name: bakeryData.name,
|
||||||
address: bakeryData.address,
|
address: bakeryData.address,
|
||||||
@@ -341,7 +324,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const newTenant = await createTenant(tenantData);
|
const newTenant = await createTenant(tenantData);
|
||||||
console.log('✅ Tenant created:', newTenant.id);
|
|
||||||
|
|
||||||
currentTenantId = newTenant.id;
|
currentTenantId = newTenant.id;
|
||||||
setTenantId(newTenant.id);
|
setTenantId(newTenant.id);
|
||||||
@@ -357,45 +339,32 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
tenant_id: currentTenantId, // Use the correct tenant ID
|
tenant_id: currentTenantId, // Use the correct tenant ID
|
||||||
user_id: user?.id
|
user_id: user?.id
|
||||||
});
|
});
|
||||||
console.log('✅ Step marked as completed');
|
|
||||||
} catch (stepError) {
|
} 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
|
// Don't throw here - step completion is not critical for UI flow
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Panadería registrada correctamente');
|
toast.success('Panadería registrada correctamente');
|
||||||
console.log('🎯 Advancing to step 2');
|
|
||||||
manualNavigation.current = true;
|
manualNavigation.current = true;
|
||||||
setCurrentStep(2);
|
setCurrentStep(2);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error creating tenant:', error);
|
|
||||||
throw new Error('Error al registrar la panadería');
|
throw new Error('Error al registrar la panadería');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate sales data without importing
|
// Validate sales data without importing
|
||||||
const validateSalesFile = async () => {
|
const validateSalesFile = async () => {
|
||||||
console.log('🔍 validateSalesFile called');
|
|
||||||
console.log('tenantId:', tenantId);
|
|
||||||
console.log('bakeryData.csvFile:', bakeryData.csvFile);
|
|
||||||
|
|
||||||
if (!tenantId || !bakeryData.csvFile) {
|
if (!tenantId || !bakeryData.csvFile) {
|
||||||
console.log('❌ Missing tenantId or csvFile');
|
|
||||||
toast.error('Falta información del tenant o archivo');
|
toast.error('Falta información del tenant o archivo');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('⏳ Setting validation status to validating');
|
|
||||||
setValidationStatus({ status: 'validating' });
|
setValidationStatus({ status: 'validating' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('📤 Calling validateSalesData API');
|
|
||||||
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
|
const validationResult = await validateSalesData(tenantId, bakeryData.csvFile);
|
||||||
console.log('📥 Validation result:', validationResult);
|
|
||||||
|
|
||||||
if (validationResult.is_valid) {
|
if (validationResult.is_valid) {
|
||||||
console.log('✅ Validation successful');
|
|
||||||
setValidationStatus({
|
setValidationStatus({
|
||||||
status: 'valid',
|
status: 'valid',
|
||||||
message: validationResult.message,
|
message: validationResult.message,
|
||||||
@@ -403,7 +372,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
});
|
});
|
||||||
toast.success('¡Archivo validado correctamente!');
|
toast.success('¡Archivo validado correctamente!');
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Validation failed');
|
|
||||||
setValidationStatus({
|
setValidationStatus({
|
||||||
status: 'invalid',
|
status: 'invalid',
|
||||||
message: validationResult.message
|
message: validationResult.message
|
||||||
@@ -411,7 +379,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
toast.error(`Error en validación: ${validationResult.message}`);
|
toast.error(`Error en validación: ${validationResult.message}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Error validating sales data:', error);
|
|
||||||
setValidationStatus({
|
setValidationStatus({
|
||||||
status: 'invalid',
|
status: 'invalid',
|
||||||
message: 'Error al validar el archivo'
|
message: 'Error al validar el archivo'
|
||||||
@@ -455,7 +422,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
// Automatically start training after successful upload
|
// Automatically start training after successful upload
|
||||||
await startTraining();
|
await startTraining();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error uploading sales data:', error);
|
// Error uploading sales data
|
||||||
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
|
const message = error?.response?.data?.detail || error.message || 'Error al subir datos de ventas';
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
@@ -498,22 +465,16 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validateCurrentStep = (): boolean => {
|
const validateCurrentStep = (): boolean => {
|
||||||
console.log(`🔍 Validating step ${currentStep}`);
|
|
||||||
console.log('Bakery data:', { name: bakeryData.name, address: bakeryData.address });
|
|
||||||
|
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
if (!bakeryData.name.trim()) {
|
if (!bakeryData.name.trim()) {
|
||||||
console.log('❌ Bakery name is empty');
|
|
||||||
toast.error('El nombre de la panadería es obligatorio');
|
toast.error('El nombre de la panadería es obligatorio');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!bakeryData.address.trim()) {
|
if (!bakeryData.address.trim()) {
|
||||||
console.log('❌ Bakery address is empty');
|
|
||||||
toast.error('La dirección es obligatoria');
|
toast.error('La dirección es obligatoria');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
console.log('✅ Step 1 validation passed');
|
|
||||||
return true;
|
return true;
|
||||||
case 2:
|
case 2:
|
||||||
if (!bakeryData.csvFile) {
|
if (!bakeryData.csvFile) {
|
||||||
@@ -591,7 +552,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
toast.success('Entrenamiento iniciado correctamente');
|
toast.success('Entrenamiento iniciado correctamente');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Training start error:', error);
|
// Training start error
|
||||||
toast.error('Error al iniciar el entrenamiento');
|
toast.error('Error al iniciar el entrenamiento');
|
||||||
setTrainingProgress(prev => ({
|
setTrainingProgress(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -623,7 +584,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
toast.success('¡Configuración completada exitosamente!');
|
toast.success('¡Configuración completada exitosamente!');
|
||||||
onComplete();
|
onComplete();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to mark final step as completed:', error);
|
// Failed to mark final step as completed
|
||||||
// Continue anyway for better UX
|
// Continue anyway for better UX
|
||||||
toast.success('¡Configuración completada exitosamente!');
|
toast.success('¡Configuración completada exitosamente!');
|
||||||
onComplete();
|
onComplete();
|
||||||
@@ -663,19 +624,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
// setLimitedAccess(true);
|
// setLimitedAccess(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Then update the EnhancedTrainingProgress call:
|
|
||||||
<EnhancedTrainingProgress
|
|
||||||
progress={{
|
|
||||||
progress: trainingProgress.progress,
|
|
||||||
status: trainingProgress.status,
|
|
||||||
currentStep: trainingProgress.currentStep,
|
|
||||||
productsCompleted: trainingProgress.productsCompleted,
|
|
||||||
productsTotal: trainingProgress.productsTotal,
|
|
||||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
|
||||||
error: trainingProgress.error
|
|
||||||
}}
|
|
||||||
onTimeout={handleTrainingTimeout}
|
|
||||||
/>
|
|
||||||
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
@@ -827,7 +775,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
type="file"
|
type="file"
|
||||||
accept=".csv,.xlsx,.xls,.json"
|
accept=".csv,.xlsx,.xls,.json"
|
||||||
required
|
required
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// Validate file size (10MB limit)
|
// Validate file size (10MB limit)
|
||||||
@@ -837,14 +785,47 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update bakery data with the selected file
|
||||||
setBakeryData(prev => ({
|
setBakeryData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
csvFile: file,
|
csvFile: file,
|
||||||
hasHistoricalData: true
|
hasHistoricalData: true
|
||||||
}));
|
}));
|
||||||
// Reset validation status when new file is selected
|
|
||||||
setValidationStatus({ status: 'idle' });
|
|
||||||
toast.success(`Archivo ${file.name} seleccionado correctamente`);
|
toast.success(`Archivo ${file.name} seleccionado correctamente`);
|
||||||
|
|
||||||
|
// Auto-validate the file after upload if tenantId exists
|
||||||
|
if (tenantId) {
|
||||||
|
setValidationStatus({ status: 'validating' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validationResult = await validateSalesData(tenantId, file);
|
||||||
|
|
||||||
|
if (validationResult.is_valid) {
|
||||||
|
setValidationStatus({
|
||||||
|
status: 'valid',
|
||||||
|
message: validationResult.message,
|
||||||
|
records: validationResult.details?.total_records || 0
|
||||||
|
});
|
||||||
|
toast.success('¡Archivo validado correctamente!');
|
||||||
|
} else {
|
||||||
|
setValidationStatus({
|
||||||
|
status: 'invalid',
|
||||||
|
message: validationResult.message
|
||||||
|
});
|
||||||
|
toast.error(`Error en validación: ${validationResult.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setValidationStatus({
|
||||||
|
status: 'invalid',
|
||||||
|
message: 'Error al validar el archivo'
|
||||||
|
});
|
||||||
|
toast.error('Error al validar el archivo');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no tenantId yet, set to idle and wait for manual validation
|
||||||
|
setValidationStatus({ status: 'idle' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
@@ -911,9 +892,6 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Debug: validationStatus = "{validationStatus.status}", tenantId = "{tenantId || 'not set'}"
|
|
||||||
</p>
|
|
||||||
{!tenantId ? (
|
{!tenantId ? (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
<p className="text-sm text-yellow-800">
|
<p className="text-sm text-yellow-800">
|
||||||
@@ -1052,68 +1030,156 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
return (
|
||||||
<EnhancedTrainingProgress
|
<SimplifiedTrainingProgress
|
||||||
progress={{
|
progress={{
|
||||||
progress: trainingProgress.progress,
|
progress: trainingProgress.progress,
|
||||||
status: trainingProgress.status,
|
status: trainingProgress.status,
|
||||||
currentStep: trainingProgress.currentStep,
|
currentStep: trainingProgress.currentStep,
|
||||||
productsCompleted: trainingProgress.productsCompleted,
|
productsCompleted: trainingProgress.productsCompleted,
|
||||||
productsTotal: trainingProgress.productsTotal,
|
productsTotal: trainingProgress.productsTotal,
|
||||||
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
estimatedTimeRemaining: trainingProgress.estimatedTimeRemaining,
|
||||||
error: trainingProgress.error
|
error: trainingProgress.error
|
||||||
}}
|
}}
|
||||||
onTimeout={() => {
|
onTimeout={() => {
|
||||||
// Handle timeout - either navigate to dashboard or show limited access
|
toast.success('El entrenamiento continuará en segundo plano. ¡Puedes empezar a explorar!');
|
||||||
console.log('Training timeout - user wants to continue to dashboard');
|
onComplete(); // Navigate to dashboard
|
||||||
// You can add your custom timeout logic here
|
}}
|
||||||
}}
|
onBackgroundMode={() => {
|
||||||
/>
|
toast.success('El entrenamiento continúa en segundo plano. ¡Bienvenido a tu panel de control!');
|
||||||
);
|
onComplete(); // Navigate to dashboard with limited features
|
||||||
|
}}
|
||||||
|
onEmailNotification={(email: string) => {
|
||||||
|
toast.success(`Te enviaremos un correo a ${email} cuando tu modelo esté listo!`);
|
||||||
|
// Here you would typically call an API to register the email notification
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case 4:
|
case 4:
|
||||||
return (
|
return (
|
||||||
<div className="text-center space-y-6">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
{/* Celebration Header */}
|
||||||
<Check className="h-8 w-8 text-green-600" />
|
<div className="text-center mb-8">
|
||||||
</div>
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 bg-gradient-to-r from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg animate-bounce">
|
||||||
|
<Check className="h-12 w-12 text-white" />
|
||||||
|
</div>
|
||||||
|
{/* Confetti effect */}
|
||||||
|
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 text-2xl animate-pulse">
|
||||||
|
🎉
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
¡Todo listo! 🎉
|
||||||
¡Configuración Completada! 🎉
|
</h2>
|
||||||
</h3>
|
<p className="text-xl text-gray-600 mb-8">
|
||||||
<p className="text-gray-600">
|
Tu asistente de IA para panadería está listo para ayudarte a predecir la demanda y hacer crecer tu negocio
|
||||||
Tu panadería está lista para usar PanIA. Comenzarás a recibir predicciones precisas de demanda.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gradient-to-r from-primary-50 to-blue-50 p-6 rounded-xl">
|
{/* Setup Summary Card */}
|
||||||
<h4 className="font-medium text-gray-900 mb-3">Resumen de configuración:</h4>
|
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 mb-8">
|
||||||
<div className="text-left space-y-2 text-sm">
|
<h3 className="text-lg font-semibold text-gray-900 mb-6 text-center">
|
||||||
<div className="flex justify-between">
|
Lo que hemos configurado para ti
|
||||||
<span className="text-gray-600">Panadería:</span>
|
</h3>
|
||||||
<span className="font-medium">{bakeryData.name}</span>
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<Store className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{bakeryData.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">Perfil de tu panadería</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<Upload className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Datos históricos</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Archivo {bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} procesado
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Productos:</span>
|
<div className="space-y-4">
|
||||||
<span className="font-medium">{bakeryData.products.length} seleccionados</span>
|
<div className="flex items-center space-x-3">
|
||||||
</div>
|
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
<div className="flex justify-between">
|
<Brain className="w-4 h-4 text-white" />
|
||||||
<span className="text-gray-600">Datos históricos:</span>
|
</div>
|
||||||
<span className="font-medium text-green-600">
|
<div>
|
||||||
✅ {bakeryData.csvFile?.name.split('.').pop()?.toUpperCase()} subido
|
<div className="font-medium text-gray-900">Modelo de IA</div>
|
||||||
</span>
|
<div className="text-sm text-gray-600">Entrenado y listo</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
</div>
|
||||||
<span className="text-gray-600">Modelo IA:</span>
|
|
||||||
<span className="font-medium text-green-600">✅ Entrenado</span>
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Predicciones inteligentes</div>
|
||||||
|
<div className="text-sm text-gray-600">Listo para usar</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 p-4 rounded-lg">
|
{/* Next Steps Preview */}
|
||||||
<p className="text-sm text-yellow-800">
|
<div className="bg-white border-2 border-blue-200 rounded-3xl p-8 mb-8">
|
||||||
💡 <strong>Próximo paso:</strong> Explora tu dashboard para ver las primeras predicciones y configurar alertas personalizadas.
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<TrendingUp className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
¿Qué sigue ahora?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Tu panel de control está listo con todo lo que necesitas para tomar decisiones más inteligentes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-2xl mb-2">📊</div>
|
||||||
|
<div className="font-medium text-gray-900 mb-1">Ver Pronósticos</div>
|
||||||
|
<div className="text-sm text-gray-600">Ve la demanda prevista para cada producto</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-2xl mb-2">🎯</div>
|
||||||
|
<div className="font-medium text-gray-900 mb-1">Planificar Producción</div>
|
||||||
|
<div className="text-sm text-gray-600">Sabe exactamente cuánto hornear</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-2xl mb-2">📈</div>
|
||||||
|
<div className="font-medium text-gray-900 mb-1">Seguir Rendimiento</div>
|
||||||
|
<div className="text-sm text-gray-600">Monitorear precisión y ganancias</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toast.success('¡Bienvenido a tu nuevo panel de control de panadería con IA! 🚀');
|
||||||
|
setTimeout(() => onComplete(), 1000); // Small delay for better UX
|
||||||
|
}}
|
||||||
|
className="bg-gradient-to-r from-primary-500 to-primary-600 text-white px-12 py-4 rounded-2xl text-lg font-semibold hover:from-primary-600 hover:to-primary-700 transform hover:scale-105 transition-all duration-200 shadow-lg"
|
||||||
|
>
|
||||||
|
Acceder al Panel de Control 🚀
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-gray-500 mt-3">
|
||||||
|
¡Listo para ver tus primeras predicciones!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1139,169 +1205,172 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 via-white to-blue-50 py-8">
|
<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">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Header */}
|
{/* Enhanced Header with Accessibility */}
|
||||||
<div className="text-center mb-8">
|
<header className="text-center mb-12" role="banner">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
<div
|
||||||
Configuración de PanIA
|
className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-r from-primary-500 to-primary-600 rounded-full mb-6"
|
||||||
|
aria-label="PanIA logo"
|
||||||
|
>
|
||||||
|
<Brain className="w-10 h-10 text-white" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Bienvenido a PanIA
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-6">
|
||||||
Configuremos tu panadería para obtener predicciones precisas de demanda
|
Configuremos tu asistente de IA para panadería en unos sencillos pasos
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Progress Summary */}
|
{/* Simplified Progress Summary with Accessibility */}
|
||||||
{progress && (
|
{progress && currentStep < 4 && (
|
||||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
<div className="max-w-md mx-auto">
|
||||||
<div className="flex items-center justify-center space-x-4">
|
<div
|
||||||
<div className="text-sm text-blue-700">
|
className="bg-white rounded-2xl shadow-lg p-6"
|
||||||
<span className="font-semibold">Progreso:</span> {Math.round(progress.completion_percentage)}%
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(progress.completion_percentage)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label="Onboarding setup progress"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Progreso de Configuración</span>
|
||||||
|
<span className="text-sm font-bold text-primary-600" aria-live="polite">
|
||||||
|
{Math.round(progress.completion_percentage)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-blue-600">
|
<div className="bg-gray-200 rounded-full h-2 overflow-hidden" aria-hidden="true">
|
||||||
({progress.steps.filter((s: any) => s.completed).length}/{progress.steps.length} pasos completados)
|
<div
|
||||||
|
className="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progress.completion_percentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500" role="status">
|
||||||
|
Paso {currentStep} de 4 • {progress.steps.filter((s: any) => s.completed).length} completados
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
{/* Modern Step Indicator with Accessibility - Only show for steps 1-2, hide during training */}
|
||||||
<div className="mb-8">
|
{currentStep < 3 && (
|
||||||
<div className="flex justify-center items-center space-x-4 mb-6">
|
<nav className="mb-12" aria-label="Onboarding progress steps" role="navigation">
|
||||||
{steps.map((step, index) => (
|
<div className="max-w-2xl mx-auto">
|
||||||
<div key={step.id} className="flex flex-col items-center">
|
<div className="flex justify-between items-center relative">
|
||||||
<div
|
{/* Connection Line */}
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all duration-300 ${
|
<div className="absolute top-6 left-6 right-6 h-0.5 bg-gray-200" aria-hidden="true">
|
||||||
currentStep > step.id
|
<div
|
||||||
? 'bg-green-500 border-green-500 text-white'
|
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 rounded-full transition-all duration-700"
|
||||||
: currentStep === step.id
|
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||||
? 'bg-primary-500 border-primary-500 text-white'
|
/>
|
||||||
: 'border-gray-300 text-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentStep > step.id ? (
|
|
||||||
<Check className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<step.icon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="mt-2 text-xs text-gray-500 text-center max-w-20">
|
|
||||||
{step.title}
|
{steps.map((step, index) => {
|
||||||
</span>
|
const isCompleted = currentStep > step.id;
|
||||||
|
const isCurrent = currentStep === step.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.id} className="flex flex-col items-center relative z-10">
|
||||||
|
<div
|
||||||
|
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-300 shadow-lg ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-green-500 text-white scale-110'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-gradient-to-r from-primary-500 to-primary-600 text-white animate-pulse'
|
||||||
|
: 'bg-white border-2 border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
aria-label={`Step ${step.id}: ${step.title}${isCompleted ? ' (completed)' : isCurrent ? ' (current)' : ''}`}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle className="h-6 w-6" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<step.icon className="h-6 w-6" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`mt-3 text-center ${
|
||||||
|
isCurrent ? 'text-primary-600 font-semibold' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{step.title}</div>
|
||||||
|
{isCurrent && (
|
||||||
|
<div className="text-xs text-primary-500 mt-1">Paso actual</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
<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: `${progress ? progress.completion_percentage : (currentStep / steps.length) * 100}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step Content */}
|
{/* Step Content with Accessibility */}
|
||||||
<div className="bg-white rounded-2xl shadow-soft p-6 mb-8">
|
<main
|
||||||
|
className={`${currentStep === 3 ? '' : 'bg-white rounded-3xl shadow-lg p-8'} mb-8`}
|
||||||
|
role="main"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={`Step ${currentStep} content`}
|
||||||
|
>
|
||||||
{renderStep()}
|
{renderStep()}
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3) and completion (step 4) */}
|
||||||
<div className="flex justify-between">
|
{currentStep < 3 && (
|
||||||
<button
|
<nav
|
||||||
onClick={handlePrevious}
|
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
|
||||||
disabled={currentStep === 1}
|
role="navigation"
|
||||||
className="flex items-center px-4 py-2 text-gray-600 disabled:text-gray-400 disabled:cursor-not-allowed hover:text-gray-800 transition-colors"
|
aria-label="Onboarding navigation"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-5 w-5 mr-1" />
|
<button
|
||||||
Anterior
|
onClick={handlePrevious}
|
||||||
</button>
|
disabled={currentStep === 1}
|
||||||
|
className="flex items-center px-6 py-3 text-gray-600 disabled:text-gray-300 disabled:cursor-not-allowed hover:text-gray-800 hover:bg-gray-50 rounded-xl transition-all group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
aria-label="Go to previous step"
|
||||||
|
tabIndex={currentStep === 1 ? -1 : 0}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5 mr-2 group-hover:-translate-x-0.5 transition-transform" aria-hidden="true" />
|
||||||
|
Paso Anterior
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Dynamic Next/Complete Button */}
|
{/* Step info */}
|
||||||
{currentStep < 3 ? (
|
<div className="text-center" role="status" aria-live="polite">
|
||||||
|
<div className="text-sm text-gray-500">Paso {currentStep} de 4</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
{currentStep === 1 && "Configura tu panadería"}
|
||||||
|
{currentStep === 2 && "Sube datos de ventas"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Next Button with contextual text */}
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
disabled={isLoading}
|
disabled={isLoading || (currentStep === 2 && validationStatus?.status !== 'valid')}
|
||||||
className="flex items-center px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
className="flex items-center px-8 py-3 bg-gradient-to-r from-primary-500 to-primary-600 text-white rounded-xl hover:from-primary-600 hover:to-primary-700 transition-all hover:shadow-lg hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none group focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
aria-label={`${currentStep === 1 ? 'Save bakery information and continue' : currentStep === 2 && validationStatus?.status === 'valid' ? 'Start AI training' : 'Continue to next step'}`}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
<Loader className="h-5 w-5 mr-2 animate-spin" aria-hidden="true" />
|
||||||
Procesando...
|
<span aria-live="polite">
|
||||||
|
{currentStep === 1 ? "Creando panadería..." : "Procesando..."}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Siguiente
|
{currentStep === 1 && "Guardar y Continuar"}
|
||||||
<ChevronRight className="h-5 w-5 ml-1" />
|
{currentStep === 2 && (
|
||||||
|
<>
|
||||||
|
{validationStatus?.status === 'valid' ? "Iniciar Entrenamiento" : "Continuar"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="h-5 w-5 ml-2 group-hover:translate-x-0.5 transition-transform" aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
) : currentStep === 3 ? (
|
</nav>
|
||||||
// Training step - show different buttons based on status
|
)}
|
||||||
<div className="flex space-x-3">
|
|
||||||
{trainingProgress.status === 'failed' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleRetryTraining}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Brain className="h-5 w-5 mr-2" />
|
|
||||||
)}
|
|
||||||
Reintentar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSkipTraining}
|
|
||||||
className="flex items-center px-4 py-2 border border-gray-300 text-gray-700 rounded-xl hover:bg-gray-50 transition-all"
|
|
||||||
>
|
|
||||||
Continuar sin entrenar
|
|
||||||
<ChevronRight className="h-5 w-5 ml-1" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : trainingProgress.status === 'completed' ? (
|
|
||||||
<button
|
|
||||||
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
|
|
||||||
<ChevronRight className="h-5 w-5 ml-1" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
// Training in progress - show status
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="flex items-center px-6 py-2 bg-gray-400 text-white rounded-xl cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
|
||||||
Entrenando... {trainingProgress.progress}%
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// Final step - Complete button
|
|
||||||
<button
|
|
||||||
onClick={handleComplete}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex items-center px-6 py-2 bg-success-500 text-white rounded-xl hover:bg-success-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<Loader className="h-5 w-5 mr-2 animate-spin" />
|
|
||||||
Completando...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Check className="h-5 w-5 mr-2" />
|
|
||||||
Ir al Dashboard
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Help Section */}
|
{/* Help Section */}
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
# Onboarding Router Test Cases
|
|
||||||
|
|
||||||
## Dashboard Access for Completed Users
|
|
||||||
|
|
||||||
### Test Case 1: Training Completed
|
|
||||||
**Given**: User has completed training (`current_step: "training_completed"`)
|
|
||||||
**When**: User logs in
|
|
||||||
**Expected**: `nextAction: "dashboard"`
|
|
||||||
**Message**: "Great! Your AI model is ready. Welcome to your dashboard!"
|
|
||||||
|
|
||||||
### Test Case 2: Dashboard Accessible
|
|
||||||
**Given**: User has reached dashboard accessible step (`current_step: "dashboard_accessible"`)
|
|
||||||
**When**: User logs in
|
|
||||||
**Expected**: `nextAction: "dashboard"`
|
|
||||||
**Message**: "Welcome back! You're ready to use your AI-powered dashboard."
|
|
||||||
|
|
||||||
### Test Case 3: High Completion Percentage
|
|
||||||
**Given**: User has `completion_percentage >= 80` but step may not be final
|
|
||||||
**When**: User logs in
|
|
||||||
**Expected**: `nextAction: "dashboard"`
|
|
||||||
|
|
||||||
### Test Case 4: Fully Completed
|
|
||||||
**Given**: User has `fully_completed: true`
|
|
||||||
**When**: User logs in
|
|
||||||
**Expected**: `nextAction: "dashboard"`
|
|
||||||
|
|
||||||
### Test Case 5: In Progress User
|
|
||||||
**Given**: User has `current_step: "sales_data_uploaded"`
|
|
||||||
**When**: User logs in
|
|
||||||
**Expected**: `nextAction: "onboarding_data"`
|
|
||||||
**Should**: Continue onboarding flow
|
|
||||||
|
|
||||||
## Manual Testing
|
|
||||||
|
|
||||||
1. Complete onboarding flow for a user
|
|
||||||
2. Log out
|
|
||||||
3. Log back in
|
|
||||||
4. Verify user goes directly to dashboard (not onboarding)
|
|
||||||
5. Check welcome message shows completion status
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
"""Initial onboarding progress tables
|
|
||||||
|
|
||||||
Revision ID: 001_initial_onboarding_tables
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-12-20 15:30:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision = '001_initial_onboarding_tables'
|
|
||||||
down_revision = None # No previous migration
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# Create users table first (if it doesn't exist)
|
|
||||||
op.create_table(
|
|
||||||
'users',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
|
||||||
sa.Column('email', sa.String(length=255), nullable=False, unique=True, index=True),
|
|
||||||
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('full_name', sa.String(length=255), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
|
|
||||||
sa.Column('is_verified', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('phone', sa.String(length=20), nullable=True),
|
|
||||||
sa.Column('language', sa.String(length=10), nullable=True, default='es'),
|
|
||||||
sa.Column('timezone', sa.String(length=50), nullable=True, default='Europe/Madrid'),
|
|
||||||
sa.Column('role', sa.String(length=20), nullable=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create refresh_tokens table
|
|
||||||
op.create_table(
|
|
||||||
'refresh_tokens',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
|
||||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
|
||||||
sa.Column('token', sa.String(length=500), nullable=False, unique=True),
|
|
||||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('is_revoked', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
|
|
||||||
# Foreign key to users table
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create user_onboarding_progress table
|
|
||||||
op.create_table(
|
|
||||||
'user_onboarding_progress',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
|
||||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, index=True),
|
|
||||||
sa.Column('step_name', sa.String(length=50), nullable=False),
|
|
||||||
sa.Column('completed', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('step_data', sa.JSON(), nullable=True, default=dict),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
|
|
||||||
# Foreign key to users table
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
|
|
||||||
# Unique constraint to prevent duplicate steps per user
|
|
||||||
sa.UniqueConstraint('user_id', 'step_name', name='uq_user_step'),
|
|
||||||
|
|
||||||
# Indexes for performance
|
|
||||||
sa.Index('ix_user_onboarding_progress_user_id', 'user_id'),
|
|
||||||
sa.Index('ix_user_onboarding_progress_step_name', 'step_name'),
|
|
||||||
sa.Index('ix_user_onboarding_progress_completed', 'completed'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create user_onboarding_summary table
|
|
||||||
op.create_table(
|
|
||||||
'user_onboarding_summary',
|
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False, primary_key=True),
|
|
||||||
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False, unique=True, index=True),
|
|
||||||
sa.Column('current_step', sa.String(length=50), nullable=False, default='user_registered'),
|
|
||||||
sa.Column('next_step', sa.String(length=50), nullable=True),
|
|
||||||
sa.Column('completion_percentage', sa.String(length=10), nullable=True, default='0.0'),
|
|
||||||
sa.Column('fully_completed', sa.Boolean(), nullable=False, default=False),
|
|
||||||
sa.Column('steps_completed_count', sa.String(length=10), nullable=True, default='0'),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column('last_activity_at', sa.DateTime(timezone=True), nullable=False),
|
|
||||||
|
|
||||||
# Foreign key to users table
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
|
|
||||||
# Indexes for performance
|
|
||||||
sa.Index('ix_user_onboarding_summary_user_id', 'user_id'),
|
|
||||||
sa.Index('ix_user_onboarding_summary_current_step', 'current_step'),
|
|
||||||
sa.Index('ix_user_onboarding_summary_fully_completed', 'fully_completed'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# Drop tables in reverse order
|
|
||||||
op.drop_table('user_onboarding_summary')
|
|
||||||
op.drop_table('user_onboarding_progress')
|
|
||||||
op.drop_table('refresh_tokens')
|
|
||||||
op.drop_table('users')
|
|
||||||
Reference in New Issue
Block a user