Add onboardin steps improvements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Components
|
||||
import LoadingSpinner from './components/ui/LoadingSpinner';
|
||||
@@ -19,6 +20,9 @@ import Layout from './components/layout/Layout';
|
||||
import { store } from './store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// Onboarding utilities
|
||||
import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter';
|
||||
|
||||
// i18n
|
||||
import './i18n';
|
||||
|
||||
@@ -33,6 +37,7 @@ interface User {
|
||||
fullName: string;
|
||||
role: string;
|
||||
isOnboardingComplete: boolean;
|
||||
tenant_id?: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
@@ -40,6 +45,7 @@ interface AppState {
|
||||
isLoading: boolean;
|
||||
user: User | null;
|
||||
currentPage: CurrentPage;
|
||||
routingDecision: RoutingDecision | null;
|
||||
}
|
||||
|
||||
const LoadingFallback = () => (
|
||||
@@ -56,9 +62,25 @@ const App: React.FC = () => {
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
currentPage: 'landing' // 👈 Start with landing page
|
||||
currentPage: 'landing',
|
||||
routingDecision: null
|
||||
});
|
||||
|
||||
// Helper function to map NextAction to CurrentPage
|
||||
const mapActionToPage = (action: NextAction): CurrentPage => {
|
||||
const actionPageMap: Record<NextAction, CurrentPage> = {
|
||||
'register': 'register',
|
||||
'login': 'login',
|
||||
'onboarding_bakery': 'onboarding',
|
||||
'onboarding_data': 'onboarding',
|
||||
'onboarding_training': 'onboarding',
|
||||
'dashboard': 'dashboard',
|
||||
'landing': 'landing'
|
||||
};
|
||||
|
||||
return actionPageMap[action] || 'landing';
|
||||
};
|
||||
|
||||
// Initialize app and check authentication
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
@@ -69,17 +91,43 @@ const App: React.FC = () => {
|
||||
|
||||
if (token && userData) {
|
||||
const user = JSON.parse(userData);
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||
});
|
||||
|
||||
try {
|
||||
// Use enhanced onboarding router to determine next action
|
||||
const routingDecision = await OnboardingRouter.getNextActionForUser();
|
||||
const nextPage = mapActionToPage(routingDecision.nextAction);
|
||||
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: nextPage,
|
||||
routingDecision
|
||||
});
|
||||
|
||||
// Show welcome message with progress
|
||||
if (routingDecision.message && routingDecision.completionPercentage > 0) {
|
||||
toast.success(`Welcome back! ${routingDecision.message} (${Math.round(routingDecision.completionPercentage)}% complete)`);
|
||||
}
|
||||
} catch (onboardingError) {
|
||||
// Fallback to legacy logic if onboarding API fails
|
||||
console.warn('Onboarding API failed, using legacy logic:', onboardingError);
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
|
||||
routingDecision: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Unauthenticated user
|
||||
const routingDecision = OnboardingRouter.getNextActionForGuest();
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Show landing page for non-authenticated users
|
||||
currentPage: 'landing',
|
||||
routingDecision
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -87,7 +135,8 @@ const App: React.FC = () => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Fallback to landing page
|
||||
currentPage: 'landing',
|
||||
routingDecision: null
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -95,16 +144,45 @@ const App: React.FC = () => {
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (user: User, token: string) => {
|
||||
const handleLogin = async (user: User, token: string) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('user_data', JSON.stringify(user));
|
||||
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||
});
|
||||
try {
|
||||
// Mark user registration as complete
|
||||
await OnboardingRouter.completeStep('user_registered', {
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
login_type: 'existing_user'
|
||||
});
|
||||
|
||||
// Determine next action based on current progress
|
||||
const routingDecision = await OnboardingRouter.getNextActionForUser();
|
||||
const nextPage = mapActionToPage(routingDecision.nextAction);
|
||||
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: nextPage,
|
||||
routingDecision
|
||||
});
|
||||
|
||||
// Show progress message
|
||||
if (routingDecision.message) {
|
||||
toast.success(routingDecision.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Enhanced login routing failed, using fallback:', error);
|
||||
// Fallback to legacy logic
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding',
|
||||
routingDecision: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -119,15 +197,42 @@ const App: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnboardingComplete = () => {
|
||||
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
currentPage: 'dashboard'
|
||||
}));
|
||||
const handleOnboardingComplete = async () => {
|
||||
try {
|
||||
// Mark all onboarding steps as complete
|
||||
await OnboardingRouter.completeStep('dashboard_accessible', {
|
||||
completion_time: new Date().toISOString(),
|
||||
user_id: appState.user?.id
|
||||
});
|
||||
|
||||
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
currentPage: 'dashboard',
|
||||
routingDecision: {
|
||||
nextAction: 'dashboard',
|
||||
currentStep: 'dashboard_accessible',
|
||||
completionPercentage: 100,
|
||||
message: 'Welcome to your PanIA dashboard!'
|
||||
}
|
||||
}));
|
||||
|
||||
toast.success('¡Configuración completada! Bienvenido a tu dashboard de PanIA 🎉');
|
||||
} catch (error) {
|
||||
console.warn('Enhanced onboarding completion failed, using fallback:', error);
|
||||
// Fallback logic
|
||||
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
currentPage: 'dashboard'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const navigateTo = (page: CurrentPage) => {
|
||||
|
||||
@@ -9,6 +9,16 @@ export { useData } from './useData';
|
||||
export { useTraining } from './useTraining';
|
||||
export { useForecast } from './useForecast';
|
||||
export { useNotification } from './useNotification';
|
||||
export { useOnboarding, useOnboardingStep } from './useOnboarding';
|
||||
|
||||
// Import hooks for combined usage
|
||||
import { useAuth } from './useAuth';
|
||||
import { useTenant } from './useTenant';
|
||||
import { useData } from './useData';
|
||||
import { useTraining } from './useTraining';
|
||||
import { useForecast } from './useForecast';
|
||||
import { useNotification } from './useNotification';
|
||||
import { useOnboarding } from './useOnboarding';
|
||||
|
||||
// Combined hook for common operations
|
||||
export const useApiHooks = () => {
|
||||
@@ -18,6 +28,7 @@ export const useApiHooks = () => {
|
||||
const training = useTraining();
|
||||
const forecast = useForecast();
|
||||
const notification = useNotification();
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
return {
|
||||
auth,
|
||||
@@ -26,5 +37,6 @@ export const useApiHooks = () => {
|
||||
training,
|
||||
forecast,
|
||||
notification,
|
||||
onboarding,
|
||||
};
|
||||
};
|
||||
194
frontend/src/api/hooks/useOnboarding.ts
Normal file
194
frontend/src/api/hooks/useOnboarding.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// frontend/src/api/hooks/useOnboarding.ts
|
||||
/**
|
||||
* Onboarding Hook
|
||||
* React hook for managing user onboarding flow and progress
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { onboardingService } from '../services/onboarding.service';
|
||||
import type { UserProgress, UpdateStepRequest } from '../services/onboarding.service';
|
||||
|
||||
export interface UseOnboardingReturn {
|
||||
progress: UserProgress | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentStep: string | null;
|
||||
nextStep: string | null;
|
||||
completionPercentage: number;
|
||||
isFullyComplete: boolean;
|
||||
|
||||
// Actions
|
||||
updateStep: (data: UpdateStepRequest) => Promise<void>;
|
||||
completeStep: (stepName: string, data?: Record<string, any>) => Promise<void>;
|
||||
resetStep: (stepName: string) => Promise<void>;
|
||||
getNextStep: () => Promise<string>;
|
||||
completeOnboarding: () => Promise<void>;
|
||||
canAccessStep: (stepName: string) => Promise<boolean>;
|
||||
refreshProgress: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useOnboarding = (): UseOnboardingReturn => {
|
||||
const [progress, setProgress] = useState<UserProgress | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Derived state
|
||||
const currentStep = progress?.current_step || null;
|
||||
const nextStep = progress?.next_step || null;
|
||||
const completionPercentage = progress?.completion_percentage || 0;
|
||||
const isFullyComplete = progress?.fully_completed || false;
|
||||
|
||||
// Load initial progress
|
||||
const loadProgress = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const userProgress = await onboardingService.getUserProgress();
|
||||
setProgress(userProgress);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load onboarding progress';
|
||||
setError(message);
|
||||
console.error('Onboarding progress load error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update step
|
||||
const updateStep = async (data: UpdateStepRequest) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedProgress = await onboardingService.updateStep(data);
|
||||
setProgress(updatedProgress);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update step';
|
||||
setError(message);
|
||||
throw err; // Re-throw so calling component can handle it
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Complete step with data
|
||||
const completeStep = async (stepName: string, data?: Record<string, any>) => {
|
||||
await updateStep({
|
||||
step_name: stepName,
|
||||
completed: true,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
// Reset step
|
||||
const resetStep = async (stepName: string) => {
|
||||
await updateStep({
|
||||
step_name: stepName,
|
||||
completed: false
|
||||
});
|
||||
};
|
||||
|
||||
// Get next step
|
||||
const getNextStep = async (): Promise<string> => {
|
||||
try {
|
||||
const result = await onboardingService.getNextStep();
|
||||
return result.step;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to get next step';
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Complete entire onboarding
|
||||
const completeOnboarding = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onboardingService.completeOnboarding();
|
||||
await loadProgress(); // Refresh progress after completion
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to complete onboarding';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user can access step
|
||||
const canAccessStep = async (stepName: string): Promise<boolean> => {
|
||||
try {
|
||||
const result = await onboardingService.canAccessStep(stepName);
|
||||
return result.can_access;
|
||||
} catch (err) {
|
||||
console.error('Can access step check failed:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh progress
|
||||
const refreshProgress = async () => {
|
||||
await loadProgress();
|
||||
};
|
||||
|
||||
// Load progress on mount
|
||||
useEffect(() => {
|
||||
loadProgress();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
progress,
|
||||
isLoading,
|
||||
error,
|
||||
currentStep,
|
||||
nextStep,
|
||||
completionPercentage,
|
||||
isFullyComplete,
|
||||
updateStep,
|
||||
completeStep,
|
||||
resetStep,
|
||||
getNextStep,
|
||||
completeOnboarding,
|
||||
canAccessStep,
|
||||
refreshProgress,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper hook for specific steps
|
||||
export const useOnboardingStep = (stepName: string) => {
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
const stepStatus = onboarding.progress?.steps.find(
|
||||
step => step.step_name === stepName
|
||||
);
|
||||
|
||||
const isCompleted = stepStatus?.completed || false;
|
||||
const stepData = stepStatus?.data || {};
|
||||
const completedAt = stepStatus?.completed_at;
|
||||
|
||||
const completeThisStep = async (data?: Record<string, any>) => {
|
||||
await onboarding.completeStep(stepName, data);
|
||||
};
|
||||
|
||||
const resetThisStep = async () => {
|
||||
await onboarding.resetStep(stepName);
|
||||
};
|
||||
|
||||
const canAccessThisStep = async (): Promise<boolean> => {
|
||||
return await onboarding.canAccessStep(stepName);
|
||||
};
|
||||
|
||||
return {
|
||||
...onboarding,
|
||||
stepName,
|
||||
isCompleted,
|
||||
stepData,
|
||||
completedAt,
|
||||
completeThisStep,
|
||||
resetThisStep,
|
||||
canAccessThisStep,
|
||||
};
|
||||
};
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
useForecast,
|
||||
useNotification,
|
||||
useApiHooks,
|
||||
useOnboarding,
|
||||
} from './hooks';
|
||||
|
||||
// Export WebSocket functionality
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DataService } from './data.service';
|
||||
import { TrainingService } from './training.service';
|
||||
import { ForecastingService } from './forecasting.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
|
||||
// Create service instances
|
||||
export const authService = new AuthService();
|
||||
@@ -19,9 +20,10 @@ export const dataService = new DataService();
|
||||
export const trainingService = new TrainingService();
|
||||
export const forecastingService = new ForecastingService();
|
||||
export const notificationService = new NotificationService();
|
||||
export const onboardingService = new OnboardingService();
|
||||
|
||||
// Export the classes as well
|
||||
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService };
|
||||
export { AuthService, TenantService, DataService, TrainingService, ForecastingService, NotificationService, OnboardingService };
|
||||
|
||||
// Import base client
|
||||
export { apiClient } from '../client';
|
||||
@@ -37,6 +39,7 @@ export const api = {
|
||||
training: trainingService,
|
||||
forecasting: forecastingService,
|
||||
notification: notificationService,
|
||||
onboarding: onboardingService,
|
||||
} as const;
|
||||
|
||||
// Service status checking
|
||||
|
||||
92
frontend/src/api/services/onboarding.service.ts
Normal file
92
frontend/src/api/services/onboarding.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// frontend/src/api/services/onboarding.service.ts
|
||||
/**
|
||||
* Onboarding Service
|
||||
* Handles user progress tracking and onboarding flow management
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface OnboardingStepStatus {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
completed_at?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UserProgress {
|
||||
user_id: string;
|
||||
steps: OnboardingStepStatus[];
|
||||
current_step: string;
|
||||
next_step?: string;
|
||||
completion_percentage: number;
|
||||
fully_completed: boolean;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface UpdateStepRequest {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class OnboardingService {
|
||||
private baseEndpoint = '/users/me/onboarding';
|
||||
|
||||
/**
|
||||
* Get user's current onboarding progress
|
||||
*/
|
||||
async getUserProgress(): Promise<UserProgress> {
|
||||
return apiClient.get(`${this.baseEndpoint}/progress`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific onboarding step
|
||||
*/
|
||||
async updateStep(data: UpdateStepRequest): Promise<UserProgress> {
|
||||
return apiClient.put(`${this.baseEndpoint}/step`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark step as completed with optional data
|
||||
*/
|
||||
async completeStep(stepName: string, data?: Record<string, any>): Promise<UserProgress> {
|
||||
return this.updateStep({
|
||||
step_name: stepName,
|
||||
completed: true,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a step (mark as incomplete)
|
||||
*/
|
||||
async resetStep(stepName: string): Promise<UserProgress> {
|
||||
return this.updateStep({
|
||||
step_name: stepName,
|
||||
completed: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next required step for user
|
||||
*/
|
||||
async getNextStep(): Promise<{ step: string; data?: Record<string, any> }> {
|
||||
return apiClient.get(`${this.baseEndpoint}/next-step`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete entire onboarding process
|
||||
*/
|
||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a specific step
|
||||
*/
|
||||
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
|
||||
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingService = new OnboardingService();
|
||||
@@ -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
|
||||
|
||||
39
frontend/src/utils/__tests__/onboardingRouter.test.md
Normal file
39
frontend/src/utils/__tests__/onboardingRouter.test.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
215
frontend/src/utils/onboardingRouter.ts
Normal file
215
frontend/src/utils/onboardingRouter.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
// frontend/src/utils/onboardingRouter.ts
|
||||
/**
|
||||
* Onboarding Router Utility
|
||||
* Determines user's next step based on their current progress
|
||||
*/
|
||||
|
||||
import { onboardingService } from '../api/services/onboarding.service';
|
||||
import type { UserProgress } from '../api/services/onboarding.service';
|
||||
|
||||
export type OnboardingStep =
|
||||
| 'user_registered'
|
||||
| 'bakery_registered'
|
||||
| 'sales_data_uploaded'
|
||||
| 'training_completed'
|
||||
| 'dashboard_accessible';
|
||||
|
||||
export type NextAction =
|
||||
| 'register'
|
||||
| 'login'
|
||||
| 'onboarding_bakery'
|
||||
| 'onboarding_data'
|
||||
| 'onboarding_training'
|
||||
| 'dashboard'
|
||||
| 'landing';
|
||||
|
||||
export interface RoutingDecision {
|
||||
nextAction: NextAction;
|
||||
currentStep: OnboardingStep | null;
|
||||
message?: string;
|
||||
canSkip?: boolean;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
export class OnboardingRouter {
|
||||
/**
|
||||
* Determine next action for authenticated user
|
||||
*/
|
||||
static async getNextActionForUser(userProgress?: UserProgress): Promise<RoutingDecision> {
|
||||
try {
|
||||
const progress = userProgress || await onboardingService.getUserProgress();
|
||||
|
||||
// Check if user has fully completed onboarding or has high completion percentage
|
||||
const isFullyCompleted = progress.fully_completed || progress.completion_percentage >= 80;
|
||||
const currentStep = progress.current_step as OnboardingStep;
|
||||
|
||||
let nextAction: NextAction;
|
||||
|
||||
if (isFullyCompleted || currentStep === 'training_completed' || currentStep === 'dashboard_accessible') {
|
||||
// User can access dashboard
|
||||
nextAction = 'dashboard';
|
||||
} else {
|
||||
// Use step-based routing
|
||||
nextAction = this.mapStepToAction(currentStep);
|
||||
}
|
||||
|
||||
return {
|
||||
nextAction,
|
||||
currentStep,
|
||||
completionPercentage: progress.completion_percentage,
|
||||
message: this.getStepMessage(currentStep),
|
||||
canSkip: this.canSkipStep(currentStep)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user progress:', error);
|
||||
|
||||
// Fallback logic when API fails
|
||||
return {
|
||||
nextAction: 'onboarding_bakery',
|
||||
currentStep: 'bakery_registered',
|
||||
completionPercentage: 0,
|
||||
message: 'Let\'s set up your bakery information'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine next action for unauthenticated user
|
||||
*/
|
||||
static getNextActionForGuest(): RoutingDecision {
|
||||
return {
|
||||
nextAction: 'landing',
|
||||
currentStep: null,
|
||||
completionPercentage: 0,
|
||||
message: 'Welcome to PanIA - AI for your bakery'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access dashboard with current progress
|
||||
*/
|
||||
static async canAccessDashboard(): Promise<boolean> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
return progress.fully_completed || progress.completion_percentage >= 80;
|
||||
} catch (error) {
|
||||
console.error('Error checking dashboard access:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dynamic onboarding step within the flow
|
||||
*/
|
||||
static async getOnboardingStepFromProgress(): Promise<{
|
||||
step: number;
|
||||
totalSteps: number;
|
||||
canProceed: boolean;
|
||||
}> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
const currentStep = progress.current_step as OnboardingStep;
|
||||
|
||||
const stepMap: Record<OnboardingStep, number> = {
|
||||
'user_registered': 1,
|
||||
'bakery_registered': 2,
|
||||
'sales_data_uploaded': 3,
|
||||
'training_completed': 4,
|
||||
'dashboard_accessible': 5
|
||||
};
|
||||
|
||||
const currentStepNumber = stepMap[currentStep] || 1;
|
||||
|
||||
return {
|
||||
step: Math.max(currentStepNumber - 1, 1), // Convert to 1-based onboarding steps
|
||||
totalSteps: 4, // We have 4 onboarding steps (excluding user registration)
|
||||
canProceed: progress.completion_percentage > 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting onboarding step:', error);
|
||||
return {
|
||||
step: 1,
|
||||
totalSteps: 4,
|
||||
canProceed: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress when user completes an action
|
||||
*/
|
||||
static async completeStep(
|
||||
step: OnboardingStep,
|
||||
data?: Record<string, any>
|
||||
): Promise<UserProgress> {
|
||||
return await onboardingService.completeStep(step, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has completed a specific step
|
||||
*/
|
||||
static async hasCompletedStep(step: OnboardingStep): Promise<boolean> {
|
||||
try {
|
||||
const progress = await onboardingService.getUserProgress();
|
||||
const stepStatus = progress.steps.find((s: any) => s.step_name === step);
|
||||
return stepStatus?.completed || false;
|
||||
} catch (error) {
|
||||
console.error(`Error checking step completion for ${step}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly message for current step
|
||||
*/
|
||||
private static getStepMessage(step: OnboardingStep): string {
|
||||
const messages: Record<OnboardingStep, string> = {
|
||||
'user_registered': 'Welcome! Your account has been created successfully.',
|
||||
'bakery_registered': 'Let\'s set up your bakery information to get started.',
|
||||
'sales_data_uploaded': 'Upload your historical sales data for better predictions.',
|
||||
'training_completed': 'Great! Your AI model is ready. Welcome to your dashboard!',
|
||||
'dashboard_accessible': 'Welcome back! You\'re ready to use your AI-powered dashboard.'
|
||||
};
|
||||
|
||||
return messages[step] || 'Continue setting up your account';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map step to corresponding frontend action
|
||||
*/
|
||||
private static mapStepToAction(step: OnboardingStep): NextAction {
|
||||
const actionMap: Record<OnboardingStep, NextAction> = {
|
||||
'user_registered': 'onboarding_bakery',
|
||||
'bakery_registered': 'onboarding_bakery',
|
||||
'sales_data_uploaded': 'onboarding_data',
|
||||
'training_completed': 'dashboard', // ✅ Users can access dashboard when training is completed
|
||||
'dashboard_accessible': 'dashboard'
|
||||
};
|
||||
|
||||
return actionMap[step] || 'onboarding_bakery';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can skip a specific step
|
||||
*/
|
||||
private static canSkipStep(step: OnboardingStep): boolean {
|
||||
// Define which steps can be skipped
|
||||
const skippableSteps: OnboardingStep[] = [
|
||||
'training_completed' // Users can access dashboard even if training is still in progress
|
||||
];
|
||||
|
||||
return skippableSteps.includes(step);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for components
|
||||
export const useOnboardingRouter = () => {
|
||||
return {
|
||||
getNextActionForUser: OnboardingRouter.getNextActionForUser,
|
||||
getNextActionForGuest: OnboardingRouter.getNextActionForGuest,
|
||||
canAccessDashboard: OnboardingRouter.canAccessDashboard,
|
||||
getOnboardingStepFromProgress: OnboardingRouter.getOnboardingStepFromProgress,
|
||||
completeStep: OnboardingRouter.completeStep,
|
||||
hasCompletedStep: OnboardingRouter.hasCompletedStep,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user