Files
bakery-ia/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx
Claude 75916adfee Make suppliers and ML training steps unconditional + rewrite completion
CHANGES:

1. **Make suppliers-setup unconditional:**
   - Removed isConditional: true and condition
   - Suppliers step now ALWAYS appears in onboarding flow
   - No longer depends on stockEntryCompleted flag

2. **Make ml-training unconditional:**
   - Removed isConditional: true and condition
   - ML training step now ALWAYS appears in onboarding flow
   - No longer depends on aiAnalysisComplete flag

3. **Completely rewrote CompletionStep content:**
   - Changed title: "¡Felicidades! Tu Sistema Está Listo"
   - Shows what user HAS ACCOMPLISHED during onboarding:
     ✓ Información de Panadería
     ✓ Inventario con IA
     ✓ Proveedores Agregados
     ✓ Recetas Configuradas
     ✓ Calidad Establecida
     ✓ Equipo Invitado
     ✓ Modelo IA Entrenado
   - REMOVED misleading "One More Thing" section that asked users
     to configure things they JUST configured
   - Changed next steps to celebrate accomplishments and guide to dashboard
   - Updated buttons: "Ir al Panel de Control →" (single clear CTA)

FIXES:
- User frustration: suppliers and ML training steps were hidden
- User confusion: completion message didn't make sense - asking to
  configure suppliers, inventory, recipes after they just did it

ONBOARDING FLOW NOW:
1. bakery-type-selection
2. setup
3. smart-inventory-setup
4. product-categorization
5. initial-stock-entry
6. suppliers-setup ✓ ALWAYS SHOWS
7. recipes-setup (conditional on bakery type)
8. production-processes (conditional on bakery type)
9. quality-setup
10. team-setup
11. ml-training ✓ ALWAYS SHOWS
12. setup-review
13. completion (celebrates accomplishments!)

Files Modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx
- frontend/src/components/domain/onboarding/steps/CompletionStep.tsx
2025-11-07 10:00:33 +00:00

534 lines
20 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '../../ui/Button';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { useAuth } from '../../../contexts/AuthContext';
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { useTenantActions } from '../../../stores/tenant.store';
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import { WizardProvider, useWizardContext, BakeryType, DataSource } from './context';
import {
BakeryTypeSelectionStep,
RegisterTenantStep,
UploadSalesDataStep,
ProductCategorizationStep,
InitialStockEntryStep,
ProductionProcessesStep,
MLTrainingStep,
CompletionStep
} from './steps';
// Import setup wizard steps
import {
SuppliersSetupStep,
RecipesSetupStep,
QualitySetupStep,
TeamSetupStep,
ReviewSetupStep,
} from '../setup-wizard/steps';
import { Building2 } from 'lucide-react';
interface StepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<any>;
isConditional?: boolean;
condition?: (context: any) => boolean;
}
interface StepProps {
onNext?: () => void;
onPrevious?: () => void;
onComplete?: (data?: any) => void;
onUpdate?: (data?: any) => void;
isFirstStep?: boolean;
isLastStep?: boolean;
initialData?: any;
}
const OnboardingWizardContent: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const wizardContext = useWizardContext();
// All possible steps with conditional visibility
// All step IDs match backend ONBOARDING_STEPS exactly
const ALL_STEPS: StepConfig[] = [
// Phase 1: Discovery
{
id: 'bakery-type-selection',
title: t('onboarding:steps.bakery_type.title', 'Tipo de Panadería'),
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
component: BakeryTypeSelectionStep,
},
// Phase 2: Core Setup
{
id: 'setup',
title: t('onboarding:steps.setup.title', 'Registrar Panadería'),
description: t('onboarding:steps.setup.description', 'Información básica'),
component: RegisterTenantStep,
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null,
},
// Phase 2a: AI-Assisted Path (ONLY PATH NOW)
{
id: 'smart-inventory-setup',
title: t('onboarding:steps.smart_inventory.title', 'Subir Datos de Ventas'),
description: t('onboarding:steps.smart_inventory.description', 'Configuración con IA'),
component: UploadSalesDataStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
},
{
id: 'product-categorization',
title: t('onboarding:steps.categorization.title', 'Categorizar Productos'),
description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'),
component: ProductCategorizationStep,
isConditional: true,
condition: (ctx) => ctx.state.aiAnalysisComplete,
},
{
id: 'initial-stock-entry',
title: t('onboarding:steps.stock.title', 'Niveles de Stock'),
description: t('onboarding:steps.stock.description', 'Cantidades iniciales'),
component: InitialStockEntryStep,
isConditional: true,
condition: (ctx) => ctx.state.categorizationCompleted,
},
{
id: 'suppliers-setup',
title: t('onboarding:steps.suppliers.title', 'Proveedores'),
description: t('onboarding:steps.suppliers.description', 'Configura tus proveedores'),
component: SuppliersSetupStep,
// Always show - no conditional
},
{
id: 'recipes-setup',
title: t('onboarding:steps.recipes.title', 'Recetas'),
description: t('onboarding:steps.recipes.description', 'Recetas de producción'),
component: RecipesSetupStep,
isConditional: true,
condition: (ctx) =>
ctx.state.bakeryType === 'production' || ctx.state.bakeryType === 'mixed',
},
{
id: 'production-processes',
title: t('onboarding:steps.processes.title', 'Procesos'),
description: t('onboarding:steps.processes.description', 'Procesos de terminado'),
component: ProductionProcessesStep,
isConditional: true,
condition: (ctx) =>
ctx.state.bakeryType === 'retail' || ctx.state.bakeryType === 'mixed',
},
// Phase 3: Advanced Features (Optional)
{
id: 'quality-setup',
title: t('onboarding:steps.quality.title', 'Calidad'),
description: t('onboarding:steps.quality.description', 'Estándares de calidad'),
component: QualitySetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
},
{
id: 'team-setup',
title: t('onboarding:steps.team.title', 'Equipo'),
description: t('onboarding:steps.team.description', 'Miembros del equipo'),
component: TeamSetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
},
// Phase 4: ML & Finalization
{
id: 'ml-training',
title: t('onboarding:steps.ml_training.title', 'Entrenamiento IA'),
description: t('onboarding:steps.ml_training.description', 'Modelo personalizado'),
component: MLTrainingStep,
// Always show - no conditional
},
{
id: 'setup-review',
title: t('onboarding:steps.review.title', 'Revisión'),
description: t('onboarding:steps.review.description', 'Confirma tu configuración'),
component: ReviewSetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
},
{
id: 'completion',
title: t('onboarding:steps.completion.title', 'Completado'),
description: t('onboarding:steps.completion.description', '¡Todo listo!'),
component: CompletionStep,
},
];
// Filter visible steps based on wizard context
// useMemo ensures VISIBLE_STEPS recalculates when wizard context state changes
// This fixes the bug where conditional steps (suppliers, ml-training) weren't showing
const VISIBLE_STEPS = useMemo(() => {
const visibleSteps = ALL_STEPS.filter(step => {
if (!step.isConditional) return true;
if (!step.condition) return true;
return step.condition(wizardContext);
});
console.log('🔄 VISIBLE_STEPS recalculated:', visibleSteps.map(s => s.id));
console.log('📊 Wizard state:', {
stockEntryCompleted: wizardContext.state.stockEntryCompleted,
aiAnalysisComplete: wizardContext.state.aiAnalysisComplete,
categorizationCompleted: wizardContext.state.categorizationCompleted,
});
return visibleSteps;
}, [wizardContext.state, wizardContext.tenantId]);
const isNewTenant = searchParams.get('new') === 'true';
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(isNewTenant);
const [canContinue, setCanContinue] = useState(true); // Track if current step allows continuation
useTenantInitializer();
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
// Auto-complete user_registered step
useEffect(() => {
if (userProgress && user?.id && !autoCompletionAttempted && !markStepCompleted.isPending) {
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('🔄 Auto-completing user_registered step for new user...');
setAutoCompletionAttempted(true);
const existingData = userRegisteredStep?.data || {};
markStepCompleted.mutate({
userId: user.id,
stepName: 'user_registered',
data: {
...existingData,
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'onboarding_wizard_auto_completion'
}
}, {
onSuccess: () => console.log('✅ user_registered step auto-completed successfully'),
onError: (error) => {
console.error('❌ Failed to auto-complete user_registered step:', error);
setAutoCompletionAttempted(false);
}
});
}
}
}, [userProgress, user?.id, autoCompletionAttempted, markStepCompleted.isPending]);
// Initialize step index based on backend progress
useEffect(() => {
if (isNewTenant) return;
if (userProgress && !isInitialized) {
console.log('🔄 Initializing onboarding progress:', userProgress);
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('⏳ Waiting for user_registered step to be auto-completed...');
return;
}
let stepIndex = 0;
if (isNewTenant) {
console.log('🆕 New tenant creation - starting from first step');
stepIndex = 0;
} else {
const currentStepFromBackend = userProgress.current_step;
stepIndex = VISIBLE_STEPS.findIndex(step => step.id === currentStepFromBackend);
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
if (stepIndex === -1) {
for (let i = 0; i < VISIBLE_STEPS.length; i++) {
const step = VISIBLE_STEPS[i];
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (!stepProgress?.completed) {
stepIndex = i;
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
break;
}
}
if (stepIndex === -1) {
stepIndex = VISIBLE_STEPS.length - 1;
console.log('✅ All steps completed, going to last step');
}
}
const firstIncompleteStepIndex = VISIBLE_STEPS.findIndex(step => {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
return !stepProgress?.completed;
});
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
stepIndex = firstIncompleteStepIndex;
}
}
console.log(`🎯 Final step index: ${stepIndex} ("${VISIBLE_STEPS[stepIndex]?.id}")`);
if (stepIndex !== currentStepIndex) {
setCurrentStepIndex(stepIndex);
}
setIsInitialized(true);
}
}, [userProgress, isInitialized, currentStepIndex, isNewTenant, VISIBLE_STEPS]);
const currentStep = VISIBLE_STEPS[currentStepIndex];
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
if (markStepCompleted.isPending) {
console.warn(`⚠️ Step completion already in progress for "${currentStep.id}", skipping duplicate call`);
return;
}
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try {
// Update wizard context based on step
if (currentStep.id === 'bakery-type-selection' && data?.bakeryType) {
wizardContext.updateBakeryType(data.bakeryType as BakeryType);
}
if (currentStep.id === 'data-source-choice' && data?.dataSource) {
wizardContext.updateDataSource(data.dataSource as DataSource);
}
if (currentStep.id === 'smart-inventory-setup' && data?.aiSuggestions) {
wizardContext.updateAISuggestions(data.aiSuggestions);
wizardContext.setAIAnalysisComplete(true);
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts);
wizardContext.markStepComplete('categorizationCompleted');
}
if (currentStep.id === 'initial-stock-entry' && data?.productsWithStock) {
wizardContext.updateProductsWithStock(data.productsWithStock);
wizardContext.markStepComplete('stockEntryCompleted');
}
if (currentStep.id === 'inventory-setup') {
wizardContext.markStepComplete('inventoryCompleted');
}
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
}
// Mark step as completed in backend
console.log(`📤 Sending API request to complete step: "${currentStep.id}"`);
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data
});
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Special handling for smart-inventory-setup
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) {
try {
console.log('🔄 Auto-completing suppliers-setup step...');
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: 'suppliers-setup',
data: {
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'inventory_creation_auto_completion',
}
});
console.log('✅ Suppliers-setup step auto-completed successfully');
} catch (supplierError) {
console.warn('⚠️ Could not auto-complete suppliers-setup step:', supplierError);
}
}
if (currentStep.id === 'completion') {
wizardContext.resetWizard();
navigate(isNewTenant ? '/app/dashboard' : '/app');
} else {
if (currentStepIndex < VISIBLE_STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
alert(`${t('onboarding:errors.step_failed', 'Error al completar paso')} "${currentStep.title}": ${errorMessage}`);
}
};
const handleStepUpdate = (data?: any) => {
// Handle canContinue state updates from setup wizard steps
if (data?.canContinue !== undefined) {
setCanContinue(data.canContinue);
}
// Handle intermediate updates without marking step complete
if (currentStep.id === 'bakery-type-selection' && data?.bakeryType) {
wizardContext.updateBakeryType(data.bakeryType as BakeryType);
}
if (currentStep.id === 'data-source-choice' && data?.dataSource) {
wizardContext.updateDataSource(data.dataSource as DataSource);
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts);
}
if (currentStep.id === 'initial-stock-entry' && data?.productsWithStock) {
wizardContext.updateProductsWithStock(data.productsWithStock);
}
};
// Show loading state
if (!isNewTenant && (isLoadingProgress || !isInitialized)) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="flex items-center justify-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)] text-sm sm:text-base">{t('common:loading', 'Cargando tu progreso...')}</p>
</div>
</CardBody>
</Card>
</div>
);
}
// Show error state
if (!isNewTenant && progressError) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="text-center space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 mx-auto bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-[var(--color-error)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-[var(--text-primary)] mb-2">
{t('onboarding:errors.network_error', 'Error al cargar progreso')}
</h2>
<p className="text-sm sm:text-base text-[var(--text-secondary)] mb-4 px-2">
{t('onboarding:errors.try_again', 'No pudimos cargar tu progreso. Puedes continuar desde el inicio.')}
</p>
<Button onClick={() => setIsInitialized(true)} variant="primary" size="lg">
{t('onboarding:wizard.navigation.next', 'Continuar')}
</Button>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
const StepComponent = currentStep.component;
const progressPercentage = isNewTenant
? ((currentStepIndex + 1) / VISIBLE_STEPS.length) * 100
: userProgress?.completion_percentage || ((currentStepIndex + 1) / VISIBLE_STEPS.length) * 100;
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
{/* Progress Header */}
<Card shadow="sm" padding="lg">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
<div className="text-center sm:text-left">
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
{isNewTenant ? t('onboarding:wizard.title_new', 'Nueva Panadería') : t('onboarding:wizard.title', 'Configuración Inicial')}
</h1>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
{t('onboarding:wizard.subtitle', 'Configura tu sistema paso a paso')}
</p>
</div>
<div className="text-center sm:text-right">
<div className="text-sm text-[var(--text-secondary)]">
{t('onboarding:wizard.progress.step_of', 'Paso {{current}} de {{total}}', {
current: currentStepIndex + 1,
total: VISIBLE_STEPS.length
})}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{Math.round(progressPercentage)}% {t('onboarding:wizard.progress.completed', 'completado')}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2 sm:h-3">
<div
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary)]/80 h-2 sm:h-3 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</Card>
{/* Step Content */}
<Card shadow="lg" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="w-5 h-5 sm:w-6 sm:h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-xs font-bold">
{currentStepIndex + 1}
</div>
</div>
<div className="flex-1">
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm">
{currentStep.description}
</p>
</div>
</div>
</CardHeader>
<CardBody padding="lg">
<StepComponent
onNext={() => {}}
onPrevious={() => {}}
onComplete={handleStepComplete}
onUpdate={handleStepUpdate}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1}
canContinue={canContinue}
/>
</CardBody>
</Card>
</div>
);
};
export const UnifiedOnboardingWizard: React.FC = () => {
return (
<WizardProvider>
<OnboardingWizardContent />
</WizardProvider>
);
};
export default UnifiedOnboardingWizard;