IMPORVE ONBOARDING STEPS

This commit is contained in:
Urtzi Alfaro
2025-11-09 09:22:08 +01:00
parent 4678f96f8f
commit cbe19a3cd1
27 changed files with 2801 additions and 1149 deletions

View File

@@ -5,23 +5,41 @@
import { apiClient } from '../client';
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
// Backend onboarding steps (full list from backend)
// Backend onboarding steps (full list from backend - UPDATED to match refactored flow)
export const BACKEND_ONBOARDING_STEPS = [
'user_registered', // Auto-completed: User account created
'setup', // Step 1: Basic bakery setup and tenant creation
'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration
'suppliers', // Step 3: Suppliers configuration (optional)
'ml-training', // Step 4: AI model training
'completion' // Step 5: Onboarding completed
'user_registered', // Phase 0: User account created (auto-completed)
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2a: File upload, validation, AI classification
'inventory-review', // Phase 2a: Review AI-detected products with type selection
'initial-stock-entry', // Phase 2a: Capture initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review all configuration
'completion' // Phase 4: Onboarding completed
];
// Frontend step order for navigation (excludes user_registered as it's auto-completed)
export const FRONTEND_STEP_ORDER = [
'setup', // Step 1: Basic bakery setup and tenant creation
'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration
'suppliers', // Step 3: Suppliers configuration (optional)
'ml-training', // Step 4: AI model training
'completion' // Step 5: Onboarding completed
'bakery-type-selection', // Phase 1: Choose bakery type
'setup', // Phase 2: Basic bakery setup and tenant creation
'upload-sales-data', // Phase 2a: File upload and AI classification
'inventory-review', // Phase 2a: Review AI-detected products
'initial-stock-entry', // Phase 2a: Initial stock levels
'product-categorization', // Phase 2b: Advanced categorization (optional)
'suppliers-setup', // Phase 2c: Suppliers configuration
'recipes-setup', // Phase 3: Production recipes (optional)
'production-processes', // Phase 3: Finishing processes (optional)
'quality-setup', // Phase 3: Quality standards (optional)
'team-setup', // Phase 3: Team members (optional)
'ml-training', // Phase 4: AI model training
'setup-review', // Phase 4: Review configuration
'completion' // Phase 4: Onboarding completed
];
export class OnboardingService {

View File

@@ -95,10 +95,11 @@ export interface IngredientCreate {
// Note: average_cost is calculated automatically from purchases (not accepted on create)
standard_cost?: number | null;
// Stock management
low_stock_threshold?: number; // Default: 10.0
reorder_point?: number; // Default: 20.0
reorder_quantity?: number; // Default: 50.0
// Stock management - all optional for onboarding
// These can be configured later based on actual usage patterns
low_stock_threshold?: number | null;
reorder_point?: number | null;
reorder_quantity?: number | null;
max_stock_level?: number | null;
// Shelf life (default value only - actual per batch)
@@ -158,9 +159,9 @@ export interface IngredientResponse {
average_cost: number | null;
last_purchase_price: number | null;
standard_cost: number | null;
low_stock_threshold: number;
reorder_point: number;
reorder_quantity: number;
low_stock_threshold: number | null; // Now optional
reorder_point: number | null; // Now optional
reorder_quantity: number | null; // Now optional
max_stock_level: number | null;
shelf_life_days: number | null; // Default value only
is_active: boolean;

View File

@@ -1,567 +0,0 @@
import React, { useState, useEffect } 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 {
RegisterTenantStep,
UploadSalesDataStep,
MLTrainingStep,
CompletionStep
} from './steps';
import { Building2 } from 'lucide-react';
interface StepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<StepProps>;
}
interface StepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
export const OnboardingWizard: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
// Steps must match backend ONBOARDING_STEPS exactly
// Note: "user_registered" is auto-completed and not shown in UI
const STEPS: StepConfig[] = [
{
id: 'setup',
title: t('onboarding:wizard.steps.setup.title', 'Registrar Panadería'),
description: t('onboarding:wizard.steps.setup.description', 'Configura la información básica de tu panadería'),
component: RegisterTenantStep,
},
{
id: 'smart-inventory-setup',
title: t('onboarding:wizard.steps.smart_inventory_setup.title', 'Configurar Inventario'),
description: t('onboarding:wizard.steps.smart_inventory_setup.description', 'Sube datos de ventas y configura tu inventario inicial'),
component: UploadSalesDataStep,
},
{
id: 'ml-training',
title: t('onboarding:wizard.steps.ml_training.title', 'Entrenamiento IA'),
description: t('onboarding:wizard.steps.ml_training.description', 'Entrena tu modelo de inteligencia artificial personalizado'),
component: MLTrainingStep,
},
{
id: 'completion',
title: t('onboarding:wizard.steps.completion.title', 'Configuración Completa'),
description: t('onboarding:wizard.steps.completion.description', '¡Bienvenido a tu sistema de gestión inteligente!'),
component: CompletionStep,
},
];
// Check if this is a fresh onboarding (new tenant creation)
const isNewTenant = searchParams.get('new') === 'true';
// Initialize state based on whether this is a new tenant or not
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(isNewTenant); // If new tenant, consider initialized immediately
// Debug log for new tenant creation
useEffect(() => {
if (isNewTenant) {
console.log('🆕 New tenant creation detected - UI will reset to step 0');
console.log('📊 Current step index:', currentStepIndex);
console.log('🎯 Is initialized:', isInitialized);
}
}, [isNewTenant, currentStepIndex, isInitialized]);
// Initialize tenant data for authenticated users
useTenantInitializer();
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
// Auto-complete user_registered step if needed (runs first)
const [autoCompletionAttempted, setAutoCompletionAttempted] = React.useState(false);
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);
// Merge with any existing data (e.g., subscription_plan from registration)
const existingData = userRegisteredStep?.data || {};
markStepCompleted.mutate({
userId: user.id,
stepName: 'user_registered',
data: {
...existingData, // Preserve existing data like subscription_plan
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'onboarding_wizard_auto_completion'
}
}, {
onSuccess: () => {
console.log('✅ user_registered step auto-completed successfully');
// The query will automatically refetch and update userProgress
},
onError: (error) => {
console.error('❌ Failed to auto-complete user_registered step:', error);
// Reset flag on error to allow retry
setAutoCompletionAttempted(false);
}
});
}
}
}, [userProgress, user?.id, autoCompletionAttempted, markStepCompleted.isPending]); // Removed markStepCompleted from deps
// Initialize step index based on backend progress with validation
useEffect(() => {
// Skip backend progress loading for new tenant creation
if (isNewTenant) {
return; // Already initialized to step 0
}
if (userProgress && !isInitialized) {
console.log('🔄 Initializing onboarding progress:', userProgress);
// Check if user_registered step is completed
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; // Wait for auto-completion to finish
}
let stepIndex = 0; // Default to first step
// If this is a new tenant creation, always start from the beginning
if (isNewTenant) {
console.log('🆕 New tenant creation - starting from first step');
stepIndex = 0;
} else {
// Find the current step index based on backend progress
const currentStepFromBackend = userProgress.current_step;
stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
// If current step is not found (e.g., suppliers step), find the next incomplete step
if (stepIndex === -1) {
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
// Find the first incomplete step that user can access
for (let i = 0; i < STEPS.length; i++) {
const step = 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 all visible steps are completed, go to last step
if (stepIndex === -1) {
stepIndex = STEPS.length - 1;
console.log('✅ All steps completed, going to last step');
}
}
// Ensure user can't skip ahead - find the first incomplete step
const firstIncompleteStepIndex = 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} ("${STEPS[stepIndex]?.id}")`);
if (stepIndex !== currentStepIndex) {
setCurrentStepIndex(stepIndex);
}
setIsInitialized(true);
}
}, [userProgress, isInitialized, currentStepIndex, isNewTenant]);
const currentStep = STEPS[currentStepIndex];
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
// Prevent concurrent mutations
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 {
// Special handling for setup step - set the created tenant in tenant store
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: auto-complete suppliers step
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) {
try {
console.log('🔄 Auto-completing suppliers step to enable ML training...');
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: 'suppliers',
data: {
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'inventory_creation_auto_completion',
message: 'Suppliers step auto-completed to proceed with ML training'
}
});
console.log('✅ Suppliers step auto-completed successfully');
} catch (supplierError) {
console.warn('⚠️ Could not auto-complete suppliers step:', supplierError);
// Don't fail the entire flow if suppliers step completion fails
}
}
if (currentStep.id === 'completion') {
// Navigate to dashboard after completion
if (isNewTenant) {
// For new tenant creation, navigate to dashboard and remove the new param
navigate('/app/dashboard');
} else {
navigate('/app');
}
} else {
// Auto-advance to next step after successful completion
if (currentStepIndex < STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
// Extract detailed error information
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
const statusCode = error?.response?.status;
console.error(`📊 Error details: Status ${statusCode}, Message: ${errorMessage}`);
// Handle different types of errors
if (statusCode === 207) {
// Multi-Status: Step updated but summary failed
console.warn(`⚠️ Partial success for step "${currentStep.id}": ${errorMessage}`);
// Continue with step advancement since the actual step was completed
if (currentStep.id === 'completion') {
// Navigate to dashboard after completion
if (isNewTenant) {
navigate('/app/dashboard');
} else {
navigate('/app');
}
} else {
// Auto-advance to next step after successful completion
if (currentStepIndex < STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
// Show a warning but don't block progress
console.warn(`Step "${currentStep.title}" completed with warnings: ${errorMessage}`);
return; // Don't show error alert
}
// Check if it's a dependency error
if (errorMessage.includes('dependencies not met')) {
console.error('🚫 Dependencies not met for step:', currentStep.id);
// Check what dependencies are missing
if (userProgress) {
console.log('📋 Current progress:', userProgress);
console.log('📋 Completed steps:', userProgress.steps.filter(s => s.completed).map(s => s.step_name));
}
}
// Don't advance automatically on real errors - user should see the issue
alert(`${t('onboarding:errors.step_failed', 'Error al completar paso')} "${currentStep.title}": ${errorMessage}`);
}
};
// Show loading state while initializing progress (skip for new tenant)
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 progress fails to load (skip for new tenant)
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 de configuración. 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;
// Calculate progress percentage - reset for new tenant creation
const progressPercentage = isNewTenant
? ((currentStepIndex + 1) / STEPS.length) * 100 // For new tenant, base progress only on current step
: userProgress?.completion_percentage || ((currentStepIndex + 1) / 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">
{/* New Tenant Info Banner */}
{isNewTenant && (
<Card className="bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border-[var(--color-primary)]/20">
<CardBody className="py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">
{t('onboarding:wizard.title', 'Creando Nueva Organización')}
</p>
<p className="text-xs text-[var(--text-secondary)]">
{t('onboarding:wizard.subtitle', 'Configurarás una nueva panadería desde cero. Este proceso es independiente de tus organizaciones existentes.')}
</p>
</div>
</div>
</CardBody>
</Card>
)}
{/* Enhanced 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', 'Crear Nueva Organización') : t('onboarding:wizard.title', 'Bienvenido a Bakery IA')}
</h1>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
{isNewTenant
? t('onboarding:wizard.subtitle', 'Configura tu nueva panadería desde cero')
: t('onboarding:wizard.subtitle', 'Configura tu sistema de gestión inteligente 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: STEPS.length })}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{Math.round(progressPercentage)}% {t('onboarding:wizard.progress.completed', 'completado')}
{isNewTenant && <span className="text-[var(--color-primary)] ml-1">(nuevo)</span>}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2 sm:h-3 mb-4">
<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>
{/* Mobile Step Indicators - Horizontal scroll on small screens */}
<div className="sm:hidden">
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
{STEPS.map((step, index) => {
// For new tenant creation, only show completed if index is less than current step
const isCompleted = isNewTenant
? index < currentStepIndex
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-shrink-0 text-center min-w-[80px] ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-1">
{isCompleted ? (
<div className="w-8 h-8 bg-[var(--color-success)] rounded-full flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-8 h-8 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold shadow-sm ring-2 ring-[var(--color-primary)]/20">
{index + 1}
</div>
) : (
<div className="w-8 h-8 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs font-medium leading-tight">
{step.title}
</div>
</div>
);
})}
</div>
</div>
{/* Desktop Step Indicators */}
<div className="hidden sm:flex sm:justify-between">
{STEPS.map((step, index) => {
// For new tenant creation, only show completed if index is less than current step
const isCompleted = isNewTenant
? index < currentStepIndex
: userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-1 text-center px-2 ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-2">
{isCompleted ? (
<div className="w-7 h-7 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-7 h-7 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold">
{index + 1}
</div>
) : (
<div className="w-7 h-7 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs sm:text-sm font-medium mb-1">
{step.title}
</div>
<div className="text-xs opacity-75">
{step.description}
</div>
</div>
);
})}
</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={() => {}} // No-op - steps must use onComplete instead
onPrevious={() => {}} // No-op - users cannot go back once they've moved forward
onComplete={handleStepComplete}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === STEPS.length - 1}
/>
</CardBody>
</Card>
</div>
);
};

View File

@@ -11,7 +11,8 @@ import { WizardProvider, useWizardContext, BakeryType, DataSource } from './cont
import {
BakeryTypeSelectionStep,
RegisterTenantStep,
UploadSalesDataStep,
FileUploadStep,
InventoryReviewStep,
ProductCategorizationStep,
InitialStockEntryStep,
ProductionProcessesStep,
@@ -73,20 +74,20 @@ const OnboardingWizardContent: React.FC = () => {
isConditional: true,
condition: (ctx) => ctx.state.bakeryType !== null,
},
// Phase 2a: AI-Assisted Path (ONLY PATH NOW)
// Phase 2a: AI-Assisted Inventory Setup (REFACTORED - split into 3 focused steps)
{
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,
id: 'upload-sales-data',
title: t('onboarding:steps.upload_sales.title', 'Subir Datos de Ventas'),
description: t('onboarding:steps.upload_sales.description', 'Cargar archivo con historial de ventas'),
component: FileUploadStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
},
{
id: 'product-categorization',
title: t('onboarding:steps.categorization.title', 'Categorizar Productos'),
description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'),
component: ProductCategorizationStep,
id: 'inventory-review',
title: t('onboarding:steps.inventory_review.title', 'Revisar Inventario'),
description: t('onboarding:steps.inventory_review.description', 'Confirmar productos detectados'),
component: InventoryReviewStep,
isConditional: true,
condition: (ctx) => ctx.state.aiAnalysisComplete,
},
@@ -96,7 +97,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.stock.description', 'Cantidades iniciales'),
component: InitialStockEntryStep,
isConditional: true,
condition: (ctx) => ctx.state.categorizationCompleted,
condition: (ctx) => ctx.state.inventoryReviewCompleted,
},
{
id: 'suppliers-setup',
@@ -130,7 +131,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.quality.description', 'Estándares de calidad'),
component: QualitySetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
},
{
id: 'team-setup',
@@ -138,7 +139,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.team.description', 'Miembros del equipo'),
component: TeamSetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
},
// Phase 4: ML & Finalization
{
@@ -154,7 +155,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.review.description', 'Confirma tu configuración'),
component: ReviewSetupStep,
isConditional: true,
condition: (ctx) => ctx.tenantId !== null,
condition: (ctx) => ctx.state.bakeryType !== null, // Tenant created after bakeryType is set
},
{
id: 'completion',
@@ -182,7 +183,7 @@ const OnboardingWizardContent: React.FC = () => {
});
return visibleSteps;
}, [wizardContext.state, wizardContext.tenantId]);
}, [wizardContext.state]);
const isNewTenant = searchParams.get('new') === 'true';
const [currentStepIndex, setCurrentStepIndex] = useState(0);
@@ -316,10 +317,15 @@ const OnboardingWizardContent: React.FC = () => {
if (currentStep.id === 'data-source-choice' && data?.dataSource) {
wizardContext.updateDataSource(data.dataSource as DataSource);
}
if (currentStep.id === 'smart-inventory-setup' && data?.aiSuggestions) {
// REFACTORED: Handle new split steps for AI-assisted inventory
if (currentStep.id === 'upload-sales-data' && data?.aiSuggestions) {
wizardContext.updateAISuggestions(data.aiSuggestions);
wizardContext.updateUploadedFile(data.uploadedFile, data.validationResult);
wizardContext.setAIAnalysisComplete(true);
}
if (currentStep.id === 'inventory-review') {
wizardContext.markStepComplete('inventoryReviewCompleted');
}
if (currentStep.id === 'product-categorization' && data?.categorizedProducts) {
wizardContext.updateCategorizedProducts(data.categorizedProducts);
wizardContext.markStepComplete('categorizationCompleted');
@@ -345,8 +351,8 @@ const OnboardingWizardContent: React.FC = () => {
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Special handling for smart-inventory-setup
if (currentStep.id === 'smart-inventory-setup' && data?.shouldAutoCompleteSuppliers) {
// Special handling for inventory-review - auto-complete suppliers if requested
if (currentStep.id === 'inventory-review' && data?.shouldAutoCompleteSuppliers) {
try {
console.log('🔄 Auto-completing suppliers-setup step...');
await markStepCompleted.mutateAsync({
@@ -452,12 +458,12 @@ const OnboardingWizardContent: React.FC = () => {
: 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">
<div className="max-w-4xl mx-auto px-2 sm:px-4 md:px-6 space-y-3 sm:space-y-4 md:space-y-6 pb-4 md: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">
<Card shadow="sm" padding="md">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-3 sm: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)]">
<h1 className="text-lg sm:text-xl md: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">
@@ -488,9 +494,9 @@ const OnboardingWizardContent: React.FC = () => {
{/* 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">
<CardHeader padding="md" divider>
<div className="flex items-center space-x-2 sm: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 flex-shrink-0">
<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>
@@ -506,7 +512,7 @@ const OnboardingWizardContent: React.FC = () => {
</div>
</CardHeader>
<CardBody padding="lg">
<CardBody padding="md">
<StepComponent
onNext={() => {}}
onPrevious={() => {}}
@@ -515,6 +521,18 @@ const OnboardingWizardContent: React.FC = () => {
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === VISIBLE_STEPS.length - 1}
canContinue={canContinue}
initialData={
// Pass AI data and file to InventoryReviewStep
currentStep.id === 'inventory-review'
? {
uploadedFile: wizardContext.state.uploadedFile,
validationResult: wizardContext.state.uploadedFileValidation,
aiSuggestions: wizardContext.state.aiSuggestions,
uploadedFileName: wizardContext.state.uploadedFileName || '',
uploadedFileSize: wizardContext.state.uploadedFileSize || 0,
}
: undefined
}
/>
</CardBody>
</Card>

View File

@@ -1,8 +1,11 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import type { ProductSuggestionResponse } from '../../../api/types/inventory';
import type { ImportValidationResponse } from '../../../api/types/dataImport';
export type BakeryType = 'production' | 'retail' | 'mixed' | null;
export type DataSource = 'ai-assisted' | 'manual' | null;
// Legacy AISuggestion type - kept for backward compatibility
export interface AISuggestion {
id: string;
name: string;
@@ -19,15 +22,18 @@ export interface WizardState {
dataSource: DataSource;
// AI-Assisted Path Data
uploadedFile?: File; // NEW: The actual file object needed for sales import API
uploadedFileName?: string;
uploadedFileSize?: number;
aiSuggestions: AISuggestion[];
uploadedFileValidation?: ImportValidationResponse; // NEW: Validation result
aiSuggestions: ProductSuggestionResponse[]; // UPDATED: Use full ProductSuggestionResponse type
aiAnalysisComplete: boolean;
categorizedProducts?: any[]; // Products with type classification
productsWithStock?: any[]; // Products with initial stock levels
// Setup Progress
categorizationCompleted: boolean;
inventoryReviewCompleted: boolean; // NEW: Tracks completion of InventoryReviewStep
stockEntryCompleted: boolean;
suppliersCompleted: boolean;
inventoryCompleted: boolean;
@@ -49,7 +55,8 @@ export interface WizardContextValue {
state: WizardState;
updateBakeryType: (type: BakeryType) => void;
updateDataSource: (source: DataSource) => void;
updateAISuggestions: (suggestions: AISuggestion[]) => void;
updateAISuggestions: (suggestions: ProductSuggestionResponse[]) => void; // UPDATED type
updateUploadedFile: (file: File, validation: ImportValidationResponse) => void; // UPDATED: store file object and validation
setAIAnalysisComplete: (complete: boolean) => void;
updateCategorizedProducts: (products: any[]) => void;
updateProductsWithStock: (products: any[]) => void;
@@ -67,6 +74,7 @@ const initialState: WizardState = {
categorizedProducts: undefined,
productsWithStock: undefined,
categorizationCompleted: false,
inventoryReviewCompleted: false, // NEW: Initially false
stockEntryCompleted: false,
suppliersCompleted: false,
inventoryCompleted: false,
@@ -118,10 +126,20 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
setState(prev => ({ ...prev, dataSource: source }));
};
const updateAISuggestions = (suggestions: AISuggestion[]) => {
const updateAISuggestions = (suggestions: ProductSuggestionResponse[]) => {
setState(prev => ({ ...prev, aiSuggestions: suggestions }));
};
const updateUploadedFile = (file: File, validation: ImportValidationResponse) => {
setState(prev => ({
...prev,
uploadedFile: file,
uploadedFileName: file.name,
uploadedFileSize: file.size,
uploadedFileValidation: validation,
}));
};
const setAIAnalysisComplete = (complete: boolean) => {
setState(prev => ({ ...prev, aiAnalysisComplete: complete }));
};
@@ -227,6 +245,7 @@ export const WizardProvider: React.FC<WizardProviderProps> = ({
updateBakeryType,
updateDataSource,
updateAISuggestions,
updateUploadedFile,
setAIAnalysisComplete,
updateCategorizedProducts,
updateProductsWithStock,

View File

@@ -1,2 +1,3 @@
export { OnboardingWizard } from './OnboardingWizard';
// OnboardingWizard.tsx has been deleted - it was deprecated and unused
// All onboarding now uses UnifiedOnboardingWizard
export { UnifiedOnboardingWizard } from './UnifiedOnboardingWizard';

View File

@@ -118,13 +118,13 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
};
return (
<div className="max-w-6xl mx-auto p-6 space-y-8">
<div className="max-w-6xl mx-auto p-4 md:p-6 space-y-6 md:space-y-8">
{/* Header */}
<div className="text-center space-y-3">
<h1 className="text-3xl font-bold text-text-primary">
<div className="text-center space-y-3 md:space-y-4">
<h1 className="text-2xl md:text-3xl font-bold text-[var(--text-primary)] px-2">
{t('onboarding:bakery_type.title', '¿Qué tipo de panadería tienes?')}
</h1>
<p className="text-lg text-text-secondary max-w-2xl mx-auto">
<p className="text-base md:text-lg text-[var(--text-secondary)] max-w-2xl mx-auto px-4">
{t(
'onboarding:bakery_type.subtitle',
'Esto nos ayudará a personalizar la experiencia y mostrarte solo las funciones que necesitas'
@@ -139,54 +139,60 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
const isHovered = hoveredType === type.id;
return (
<Card
<button
key={type.id}
className={`
relative cursor-pointer transition-all duration-300 overflow-hidden
${isSelected ? 'ring-4 ring-primary-500 shadow-2xl scale-105' : 'hover:shadow-xl hover:scale-102'}
${isHovered && !isSelected ? 'shadow-lg' : ''}
`}
type="button"
onClick={() => handleSelectType(type.id)}
onMouseEnter={() => setHoveredType(type.id)}
onMouseLeave={() => setHoveredType(null)}
className={`
relative cursor-pointer transition-all duration-300 overflow-hidden
border-2 rounded-lg text-left w-full
bg-[var(--bg-secondary)]
${isSelected
? 'border-[var(--color-primary)] shadow-lg ring-2 ring-[var(--color-primary)]/50 scale-[1.02]'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:shadow-md'
}
${isHovered && !isSelected ? 'shadow-sm' : ''}
`}
>
{/* Selection Indicator */}
{isSelected && (
<div className="absolute top-4 right-4 z-10">
<div className="w-8 h-8 bg-primary-500 rounded-full flex items-center justify-center shadow-lg animate-scale-in">
<div className="w-8 h-8 bg-[var(--color-primary)] rounded-full flex items-center justify-center shadow-lg">
<Check className="w-5 h-5 text-white" strokeWidth={3} />
</div>
</div>
)}
{/* Gradient Background */}
<div className={`absolute inset-0 ${type.gradient} opacity-40 transition-opacity ${isSelected ? 'opacity-60' : ''}`} />
{/* Accent Background */}
<div className={`absolute inset-0 bg-[var(--color-primary)]/5 transition-opacity ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
{/* Content */}
<div className="relative p-6 space-y-4">
<div className="relative p-4 md:p-6 space-y-3 md:space-y-4">
{/* Icon & Title */}
<div className="space-y-3">
<div className="text-5xl">{type.icon}</div>
<h3 className="text-xl font-bold text-text-primary">
<div className="space-y-2 md:space-y-3">
<div className="text-4xl md:text-5xl">{type.icon}</div>
<h3 className="text-lg md:text-xl font-bold text-[var(--text-primary)]">
{type.name}
</h3>
<p className="text-sm text-text-secondary leading-relaxed">
<p className="text-sm text-[var(--text-secondary)] leading-relaxed">
{type.description}
</p>
</div>
{/* Features */}
<div className="space-y-2 pt-2">
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wide">
<h4 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{t('onboarding:bakery_type.features_label', 'Características')}
</h4>
<ul className="space-y-1.5">
{type.features.map((feature, index) => (
<li
key={index}
className="text-sm text-text-primary flex items-start gap-2"
className="text-sm text-[var(--text-primary)] flex items-start gap-2"
>
<span className="text-primary-500 mt-0.5 flex-shrink-0"></span>
<span className="text-[var(--color-primary)] mt-0.5 flex-shrink-0"></span>
<span>{feature}</span>
</li>
))}
@@ -194,15 +200,15 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div>
{/* Examples */}
<div className="space-y-2 pt-2 border-t border-border-primary">
<h4 className="text-xs font-semibold text-text-secondary uppercase tracking-wide">
<div className="space-y-2 pt-2 border-t border-[var(--border-color)]">
<h4 className="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{t('onboarding:bakery_type.examples_label', 'Ejemplos')}
</h4>
<div className="flex flex-wrap gap-2">
{type.examples.map((example, index) => (
<span
key={index}
className="text-xs px-2 py-1 bg-bg-secondary rounded-full text-text-secondary"
className="text-xs px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-full text-[var(--text-secondary)]"
>
{example}
</span>
@@ -210,45 +216,23 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div>
</div>
</div>
</Card>
</button>
);
})}
</div>
{/* Help Text */}
<div className="text-center space-y-4">
<p className="text-sm text-text-secondary">
{t(
'onboarding:bakery_type.help_text',
'💡 No te preocupes, siempre puedes cambiar esto más tarde en la configuración'
)}
</p>
{/* Continue Button */}
<div className="flex justify-center pt-4">
<Button
onClick={handleContinue}
disabled={!selectedType}
size="lg"
className="min-w-[200px]"
>
{t('onboarding:bakery_type.continue_button', 'Continuar')}
</Button>
</div>
</div>
{/* Additional Info */}
{selectedType && (
<div className="mt-8 p-6 bg-primary-50 border border-primary-200 rounded-lg animate-fade-in">
<div className="bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded-lg p-4 md:p-6">
<div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0">
<div className="text-2xl md:text-3xl flex-shrink-0">
{bakeryTypes.find(t => t.id === selectedType)?.icon}
</div>
<div className="space-y-2">
<h4 className="font-semibold text-text-primary">
<h4 className="font-semibold text-[var(--text-primary)]">
{t('onboarding:bakery_type.selected_info_title', 'Perfecto para tu panadería')}
</h4>
<p className="text-sm text-text-secondary">
<p className="text-sm text-[var(--text-secondary)]">
{selectedType === 'production' &&
t(
'onboarding:bakery_type.production.selected_info',
@@ -269,6 +253,27 @@ export const BakeryTypeSelectionStep: React.FC<BakeryTypeSelectionStepProps> = (
</div>
</div>
)}
{/* Help Text & Continue Button */}
<div className="text-center space-y-4">
<p className="text-sm text-[var(--text-secondary)]">
{t(
'onboarding:bakery_type.help_text',
'💡 No te preocupes, siempre puedes cambiar esto más tarde en la configuración'
)}
</p>
<div className="flex justify-center pt-2">
<Button
onClick={handleContinue}
disabled={!selectedType}
size="lg"
className="w-full sm:w-auto sm:min-w-[200px]"
>
{t('onboarding:bakery_type.continue_button', 'Continuar')}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,322 @@
import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useClassifyBatch } from '../../../../api/hooks/inventory';
import { useValidateImportFile } from '../../../../api/hooks/sales';
import type { ImportValidationResponse } from '../../../../api/types/dataImport';
import type { ProductSuggestionResponse } from '../../../../api/types/inventory';
import { Upload, FileText, AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
interface FileUploadStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data: {
uploadedFile: File; // NEW: Pass the file object for sales import
validationResult: ImportValidationResponse;
aiSuggestions: ProductSuggestionResponse[];
uploadedFileName: string;
uploadedFileSize: number;
}) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
interface ProgressState {
stage: 'preparing' | 'validating' | 'analyzing' | 'classifying';
progress: number;
message: string;
}
export const FileUploadStep: React.FC<FileUploadStepProps> = ({
onComplete,
onPrevious,
isFirstStep
}) => {
const { t } = useTranslation();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string>('');
const [progressState, setProgressState] = useState<ProgressState | null>(null);
const [showGuide, setShowGuide] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentTenant = useCurrentTenant();
// API hooks
const validateFileMutation = useValidateImportFile();
const classifyBatchMutation = useClassifyBatch();
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
setError('');
}
};
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
setSelectedFile(file);
setError('');
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
};
const handleUploadAndProcess = async () => {
if (!selectedFile || !currentTenant?.id) return;
setIsProcessing(true);
setError('');
setProgressState({
stage: 'preparing',
progress: 10,
message: t('onboarding:file_upload.preparing', 'Preparando archivo...')
});
try {
// Step 1: Validate the file
setProgressState({
stage: 'validating',
progress: 30,
message: t('onboarding:file_upload.validating', 'Validando formato del archivo...')
});
const validationResult = await validateFileMutation.mutateAsync({
tenantId: currentTenant.id,
file: selectedFile
});
if (!validationResult || validationResult.is_valid === undefined) {
throw new Error('Invalid validation response from server');
}
if (!validationResult.is_valid) {
const errorMsg = validationResult.errors?.join(', ') || 'Archivo inválido';
throw new Error(errorMsg);
}
// Step 2: Extract product list
setProgressState({
stage: 'analyzing',
progress: 50,
message: t('onboarding:file_upload.analyzing', 'Analizando productos en el archivo...')
});
const products = validationResult.product_list?.map((productName: string) => ({
product_name: productName
})) || [];
if (products.length === 0) {
throw new Error(t('onboarding:file_upload.no_products', 'No se encontraron productos en el archivo'));
}
// Step 3: AI Classification
setProgressState({
stage: 'classifying',
progress: 75,
message: t('onboarding:file_upload.classifying', `Clasificando ${products.length} productos con IA...`)
});
const classificationResponse = await classifyBatchMutation.mutateAsync({
tenantId: currentTenant.id,
products
});
// Step 4: Complete with success
setProgressState({
stage: 'classifying',
progress: 100,
message: t('onboarding:file_upload.success', '¡Análisis completado!')
});
// Pass data to parent and move to next step
setTimeout(() => {
onComplete({
uploadedFile: selectedFile, // NEW: Pass the file for sales import
validationResult,
aiSuggestions: classificationResponse.suggestions,
uploadedFileName: selectedFile.name,
uploadedFileSize: selectedFile.size,
});
}, 500);
} catch (err) {
console.error('Error processing file:', err);
setError(err instanceof Error ? err.message : 'Error procesando archivo');
setProgressState(null);
setIsProcessing(false);
}
};
const handleRemoveFile = () => {
setSelectedFile(null);
setError('');
setProgressState(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
{t('onboarding:file_upload.title', 'Subir Datos de Ventas')}
</h2>
<p className="text-sm md:text-base text-[var(--text-secondary)]">
{t('onboarding:file_upload.description', 'Sube un archivo con tus datos de ventas y nuestro sistema detectará automáticamente tus productos')}
</p>
</div>
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('onboarding:file_upload.why', 'Analizaremos tus datos de ventas históricas para configurar automáticamente tu inventario inicial con inteligencia artificial, ahorrándote horas de trabajo manual.')}
</p>
</div>
{/* File Upload Area */}
{!selectedFile && !isProcessing && (
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
className="border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-6 md:p-8 text-center hover:border-[var(--color-primary)]/50 transition-colors cursor-pointer min-h-[200px] flex flex-col items-center justify-center"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-10 h-10 md:w-12 md:h-12 mx-auto mb-3 md:mb-4 text-[var(--color-primary)]/50" />
<h3 className="text-base md:text-lg font-medium text-[var(--text-primary)] mb-2 px-4">
{t('onboarding:file_upload.drop_zone_title', 'Arrastra tu archivo aquí')}
</h3>
<p className="text-sm text-[var(--text-secondary)] mb-3 md:mb-4">
{t('onboarding:file_upload.drop_zone_subtitle', 'o haz clic para seleccionar')}
</p>
<p className="text-xs text-[var(--text-secondary)] px-4">
{t('onboarding:file_upload.formats', 'Formatos soportados: CSV, JSON (máx. 10MB)')}
</p>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
onChange={handleFileSelect}
className="hidden"
/>
</div>
)}
{/* Selected File Preview */}
{selectedFile && !isProcessing && (
<div className="border border-[var(--color-success)] bg-[var(--color-success)]/5 rounded-lg p-3 md:p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
<FileText className="w-8 h-8 md:w-10 md:h-10 text-[var(--color-success)] flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium text-[var(--text-primary)] text-sm md:text-base truncate">{selectedFile.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
<button
onClick={handleRemoveFile}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] p-2"
>
</button>
</div>
</div>
)}
{/* Progress Indicator */}
{isProcessing && progressState && (
<div className="border border-[var(--color-primary)] rounded-lg p-6 bg-[var(--color-primary)]/5">
<div className="flex items-center gap-3 mb-4">
<Loader2 className="w-6 h-6 animate-spin text-[var(--color-primary)]" />
<div className="flex-1">
<p className="font-medium text-[var(--text-primary)]">{progressState.message}</p>
<div className="mt-2 bg-[var(--bg-secondary)] rounded-full h-2">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressState.progress}%` }}
/>
</div>
</div>
</div>
<p className="text-sm text-[var(--text-secondary)] text-center">
{progressState.progress}% completado
</p>
</div>
)}
{/* Error Display */}
{error && (
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-danger)] flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-[var(--color-danger)] mb-1">Error</p>
<p className="text-sm text-[var(--text-secondary)]">{error}</p>
</div>
</div>
</div>
)}
{/* Help Guide Toggle */}
<button
onClick={() => setShowGuide(!showGuide)}
className="text-sm text-[var(--color-primary)] hover:underline"
>
{showGuide ? '▼' : '▶'} {t('onboarding:file_upload.show_guide', '¿Necesitas ayuda con el formato del archivo?')}
</button>
{showGuide && (
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 text-sm space-y-2">
<p className="font-medium text-[var(--text-primary)]">
{t('onboarding:file_upload.guide_title', 'Formato requerido del archivo:')}
</p>
<ul className="list-disc list-inside space-y-1 text-[var(--text-secondary)]">
<li>{t('onboarding:file_upload.guide_1', 'Columnas: Fecha, Producto, Cantidad')}</li>
<li>{t('onboarding:file_upload.guide_2', 'Formato de fecha: YYYY-MM-DD')}</li>
<li>{t('onboarding:file_upload.guide_3', 'Los nombres de productos deben ser consistentes')}</li>
<li>{t('onboarding:file_upload.guide_4', 'Ejemplo: 2024-01-15,Pan de Molde,25')}</li>
</ul>
</div>
)}
{/* Navigation Buttons */}
<div className="flex justify-between gap-4 pt-6 border-t">
<Button
variant="outline"
onClick={onPrevious}
disabled={isProcessing || isFirstStep}
>
{t('common:back', '← Atrás')}
</Button>
<Button
onClick={handleUploadAndProcess}
disabled={!selectedFile || isProcessing}
className="min-w-[200px]"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('onboarding:file_upload.processing', 'Procesando...')}
</>
) : (
t('onboarding:file_upload.continue', 'Analizar y Continuar →')
)}
</Button>
</div>
</div>
);
};

View File

@@ -15,7 +15,7 @@ export interface ProductWithStock {
}
export interface InitialStockEntryStepProps {
products: ProductWithStock[];
products?: ProductWithStock[]; // Made optional - will use empty array if not provided
onUpdate?: (data: { productsWithStock: ProductWithStock[] }) => void;
onComplete?: () => void;
onPrevious?: () => void;
@@ -36,6 +36,10 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
if (initialData?.productsWithStock) {
return initialData.productsWithStock;
}
// Handle case where initialProducts is undefined (shouldn't happen, but defensive)
if (!initialProducts || initialProducts.length === 0) {
return [];
}
return initialProducts.map(p => ({
...p,
initialStock: p.initialStock ?? undefined,
@@ -78,17 +82,37 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
const productsWithStock = products.filter(p => p.initialStock !== undefined && p.initialStock >= 0);
const productsWithoutStock = products.filter(p => p.initialStock === undefined);
const completionPercentage = (productsWithStock.length / products.length) * 100;
const completionPercentage = products.length > 0 ? (productsWithStock.length / products.length) * 100 : 100;
const allCompleted = productsWithoutStock.length === 0;
// If no products, show a skip message
if (products.length === 0) {
return (
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
<div className="text-center space-y-4">
<div className="text-6xl"></div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)]">
{t('onboarding:stock.no_products_title', 'Stock Inicial')}
</h2>
<p className="text-[var(--text-secondary)]">
{t('onboarding:stock.no_products_message', 'Podrás configurar los niveles de stock más tarde en la sección de inventario.')}
</p>
<Button onClick={handleContinue} variant="primary" rightIcon={<ArrowRight />}>
{t('common:continue', 'Continuar')}
</Button>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<h1 className="text-2xl font-bold text-text-primary">
<div className="text-center space-y-2 md:space-y-3">
<h1 className="text-xl md:text-2xl font-bold text-text-primary px-2">
{t('onboarding:stock.title', 'Niveles de Stock Inicial')}
</h1>
<p className="text-text-secondary max-w-2xl mx-auto">
<p className="text-sm md:text-base text-text-secondary max-w-2xl mx-auto px-4">
{t(
'onboarding:stock.subtitle',
'Ingresa las cantidades actuales de cada producto. Esto permite que el sistema rastree el inventario desde hoy.'
@@ -133,11 +157,11 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
</div>
{/* Quick Actions */}
<div className="flex justify-between items-center">
<Button onClick={handleSetAllToZero} variant="outline" size="sm">
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2">
<Button onClick={handleSetAllToZero} variant="outline" size="sm" className="w-full sm:w-auto">
{t('onboarding:stock.set_all_zero', 'Establecer todo a 0')}
</Button>
<Button onClick={handleSkipForNow} variant="ghost" size="sm">
<Button onClick={handleSkipForNow} variant="ghost" size="sm" className="w-full sm:w-auto">
{t('onboarding:stock.skip_for_now', 'Omitir por ahora (se establecerá a 0)')}
</Button>
</div>
@@ -178,7 +202,7 @@ export const InitialStockEntryStep: React.FC<InitialStockEntryStepProps> = ({
placeholder="0"
min="0"
step="0.01"
className="w-24 text-right"
className="w-20 sm:w-24 text-right min-h-[44px]"
/>
<span className="text-sm text-text-secondary whitespace-nowrap">
{product.unit || 'kg'}

View File

@@ -0,0 +1,860 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateIngredient } from '../../../../api/hooks/inventory';
import { useImportSalesData } from '../../../../api/hooks/sales';
import type { ProductSuggestionResponse, IngredientCreate } from '../../../../api/types/inventory';
import { ProductType, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../../api/types/inventory';
import { Package, ShoppingBag, AlertCircle, CheckCircle2, Edit2, Trash2, Plus, Sparkles } from 'lucide-react';
interface InventoryReviewStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data: {
inventoryItemsCreated: number;
salesDataImported: boolean;
}) => void;
isFirstStep: boolean;
isLastStep: boolean;
initialData?: {
uploadedFile?: File; // NEW: File object for sales import
validationResult?: any; // NEW: Validation result
aiSuggestions: ProductSuggestionResponse[];
uploadedFileName: string;
uploadedFileSize: number;
};
}
interface InventoryItemForm {
id: string; // Unique ID for UI tracking
name: string;
product_type: ProductType;
category: string;
unit_of_measure: UnitOfMeasure | string;
// AI suggestion metadata (if from AI)
isSuggested: boolean;
confidence_score?: number;
sales_data?: {
total_quantity: number;
average_daily_sales: number;
};
}
type FilterType = 'all' | 'ingredients' | 'finished_products';
// Template Definitions - Common Bakery Ingredients
interface TemplateItem {
name: string;
product_type: ProductType;
category: string;
unit_of_measure: UnitOfMeasure;
}
interface IngredientTemplate {
id: string;
name: string;
description: string;
icon: string;
items: TemplateItem[];
}
const INGREDIENT_TEMPLATES: IngredientTemplate[] = [
{
id: 'basic-bakery',
name: 'Ingredientes Básicos de Panadería',
description: 'Esenciales para cualquier panadería',
icon: '🍞',
items: [
{ name: 'Harina de Trigo', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Azúcar', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Sal', product_type: ProductType.INGREDIENT, category: IngredientCategory.SALT, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Levadura Fresca', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Agua', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.LITERS },
],
},
{
id: 'pastry-essentials',
name: 'Esenciales para Pastelería',
description: 'Ingredientes para pasteles y postres',
icon: '🎂',
items: [
{ name: 'Huevos', product_type: ProductType.INGREDIENT, category: IngredientCategory.EGGS, unit_of_measure: UnitOfMeasure.UNITS },
{ name: 'Mantequilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.DAIRY, unit_of_measure: UnitOfMeasure.LITERS },
{ name: 'Vainilla', product_type: ProductType.INGREDIENT, category: IngredientCategory.SPICES, unit_of_measure: UnitOfMeasure.MILLILITERS },
{ name: 'Azúcar Glass', product_type: ProductType.INGREDIENT, category: IngredientCategory.SUGAR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
{
id: 'bread-basics',
name: 'Básicos para Pan Artesanal',
description: 'Todo lo necesario para pan artesanal',
icon: '🥖',
items: [
{ name: 'Harina Integral', product_type: ProductType.INGREDIENT, category: IngredientCategory.FLOUR, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Masa Madre', product_type: ProductType.INGREDIENT, category: IngredientCategory.YEAST, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Aceite de Oliva', product_type: ProductType.INGREDIENT, category: IngredientCategory.FATS, unit_of_measure: UnitOfMeasure.LITERS },
{ name: 'Semillas de Sésamo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
{
id: 'chocolate-specialties',
name: 'Especialidades de Chocolate',
description: 'Para productos con chocolate',
icon: '🍫',
items: [
{ name: 'Chocolate Negro', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Cacao en Polvo', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Chocolate con Leche', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
{ name: 'Crema de Avellanas', product_type: ProductType.INGREDIENT, category: IngredientCategory.OTHER, unit_of_measure: UnitOfMeasure.KILOGRAMS },
],
},
];
export const InventoryReviewStep: React.FC<InventoryReviewStepProps> = ({
onComplete,
onPrevious,
isFirstStep,
initialData
}) => {
const { t } = useTranslation();
const [inventoryItems, setInventoryItems] = useState<InventoryItemForm[]>([]);
const [activeFilter, setActiveFilter] = useState<FilterType>('all');
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<InventoryItemForm>({
id: '',
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API hooks
const createIngredientMutation = useCreateIngredient();
const importSalesMutation = useImportSalesData();
// Initialize with AI suggestions
useEffect(() => {
if (initialData?.aiSuggestions) {
const items: InventoryItemForm[] = initialData.aiSuggestions.map((suggestion, index) => ({
id: `ai-${index}-${Date.now()}`,
name: suggestion.suggested_name,
product_type: suggestion.product_type as ProductType,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure as UnitOfMeasure,
isSuggested: true,
confidence_score: suggestion.confidence_score,
sales_data: suggestion.sales_data ? {
total_quantity: suggestion.sales_data.total_quantity,
average_daily_sales: suggestion.sales_data.average_daily_sales,
} : undefined,
}));
setInventoryItems(items);
}
}, [initialData]);
// Filter items
const filteredItems = inventoryItems.filter(item => {
if (activeFilter === 'ingredients') return item.product_type === ProductType.INGREDIENT;
if (activeFilter === 'finished_products') return item.product_type === ProductType.FINISHED_PRODUCT;
return true;
});
// Count by type
const counts = {
all: inventoryItems.length,
ingredients: inventoryItems.filter(i => i.product_type === ProductType.INGREDIENT).length,
finished_products: inventoryItems.filter(i => i.product_type === ProductType.FINISHED_PRODUCT).length,
};
// Form handlers
const handleAdd = () => {
setFormData({
id: `manual-${Date.now()}`,
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
setEditingId(null);
setIsAdding(true);
setFormErrors({});
};
const handleEdit = (item: InventoryItemForm) => {
setFormData({ ...item });
setEditingId(item.id);
setIsAdding(true);
setFormErrors({});
};
const handleDelete = (id: string) => {
setInventoryItems(items => items.filter(item => item.id !== id));
};
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
if (!formData.name.trim()) {
errors.name = t('validation:name_required', 'El nombre es requerido');
}
if (!formData.category) {
errors.category = t('validation:category_required', 'La categoría es requerida');
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
};
const handleSave = () => {
if (!validateForm()) return;
if (editingId) {
// Update existing
setInventoryItems(items =>
items.map(item => (item.id === editingId ? formData : item))
);
} else {
// Add new
setInventoryItems(items => [...items, formData]);
}
setIsAdding(false);
setEditingId(null);
setFormData({
id: '',
name: '',
product_type: ProductType.INGREDIENT,
category: '',
unit_of_measure: UnitOfMeasure.KILOGRAMS,
isSuggested: false,
});
};
const handleCancel = () => {
setIsAdding(false);
setEditingId(null);
setFormErrors({});
};
const handleAddTemplate = (template: IngredientTemplate) => {
// Check for duplicates by name
const existingNames = new Set(inventoryItems.map(item => item.name.toLowerCase()));
const newItems = template.items
.filter(item => !existingNames.has(item.name.toLowerCase()))
.map((item, index) => ({
id: `template-${template.id}-${index}-${Date.now()}`,
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure,
isSuggested: false,
}));
if (newItems.length > 0) {
setInventoryItems(items => [...items, ...newItems]);
}
};
const handleCompleteStep = async () => {
if (inventoryItems.length === 0) {
setFormErrors({ submit: t('validation:min_items', 'Agrega al menos 1 producto para continuar') });
return;
}
setIsSubmitting(true);
setFormErrors({});
try {
// STEP 1: Create all inventory items in parallel
// This MUST happen BEFORE sales import because sales records reference inventory IDs
console.log('📦 Creating inventory items...', inventoryItems.length);
console.log('📋 Items to create:', inventoryItems.map(item => ({
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure
})));
const createPromises = inventoryItems.map((item, index) => {
const ingredientData: IngredientCreate = {
name: item.name,
product_type: item.product_type,
category: item.category,
unit_of_measure: item.unit_of_measure as UnitOfMeasure,
// All other fields are optional now!
};
console.log(`🔄 Creating ingredient ${index + 1}/${inventoryItems.length}:`, ingredientData);
return createIngredientMutation.mutateAsync({
tenantId,
ingredientData,
}).catch(error => {
console.error(`❌ Failed to create ingredient "${item.name}":`, error);
console.error('Failed ingredient data:', ingredientData);
throw error;
});
});
await Promise.all(createPromises);
console.log('✅ Inventory items created successfully');
// STEP 2: Import sales data (only if file was uploaded)
// Now that inventory exists, sales records can reference the inventory IDs
let salesImported = false;
if (initialData?.uploadedFile && tenantId) {
try {
console.log('📊 Importing sales data from file:', initialData.uploadedFileName);
await importSalesMutation.mutateAsync({
tenantId,
file: initialData.uploadedFile,
});
salesImported = true;
console.log('✅ Sales data imported successfully');
} catch (salesError) {
console.error('⚠️ Sales import failed (non-blocking):', salesError);
// Don't block onboarding if sales import fails
// Inventory is already created, which is the critical part
}
}
// Complete the step with metadata
onComplete({
inventoryItemsCreated: inventoryItems.length,
salesDataImported: salesImported,
});
} catch (error) {
console.error('Error creating inventory items:', error);
setFormErrors({ submit: t('error:creating_items', 'Error al crear los productos. Inténtalo de nuevo.') });
setIsSubmitting(false);
}
};
// Category options based on product type
const getCategoryOptions = (productType: ProductType) => {
if (productType === ProductType.INGREDIENT) {
return Object.values(IngredientCategory).map(cat => ({
value: cat,
label: t(`inventory:enums.ingredient_category.${cat}`, cat)
}));
} else {
return Object.values(ProductCategory).map(cat => ({
value: cat,
label: t(`inventory:enums.product_category.${cat}`, cat)
}));
}
};
const unitOptions = Object.values(UnitOfMeasure).map(unit => ({
value: unit,
label: t(`inventory:enums.unit_of_measure.${unit}`, unit)
}));
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-xl md:text-2xl font-bold text-[var(--text-primary)] mb-2">
{t('onboarding:inventory_review.title', 'Revisar Inventario')}
</h2>
<p className="text-sm md:text-base text-[var(--text-secondary)]">
{t('onboarding:inventory_review.description', 'Revisa y ajusta los productos detectados. Puedes editar, eliminar o agregar más productos.')}
</p>
</div>
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-[var(--color-info)]" />
{t('setup_wizard:why_this_matters', '¿Por qué es importante?')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('onboarding:inventory_review.why', 'Estos productos serán la base de tu sistema. Diferenciamos entre Ingredientes (lo que usas para producir) y Productos Terminados (lo que vendes).')}
</p>
</div>
{/* Quick Add Templates */}
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-900/10 dark:to-blue-900/10 border border-purple-200 dark:border-purple-700 rounded-lg p-5">
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<h3 className="font-semibold text-[var(--text-primary)]">
{t('inventory:templates.title', 'Plantillas de Ingredientes')}
</h3>
</div>
<p className="text-sm text-[var(--text-secondary)] mb-4">
{t('inventory:templates.description', 'Agrega ingredientes comunes con un solo clic. Solo se agregarán los que no tengas ya.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{INGREDIENT_TEMPLATES.map((template) => (
<button
key={template.id}
onClick={() => handleAddTemplate(template)}
className="text-left p-4 bg-white dark:bg-gray-800 border-2 border-purple-200 dark:border-purple-700 rounded-lg hover:border-purple-400 dark:hover:border-purple-500 hover:shadow-md transition-all group"
>
<div className="flex items-start gap-3">
<span className="text-3xl group-hover:scale-110 transition-transform">{template.icon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-[var(--text-primary)] mb-1">
{template.name}
</h4>
<p className="text-xs text-[var(--text-secondary)] mb-2">
{template.description}
</p>
<p className="text-xs text-purple-600 dark:text-purple-400 font-medium">
{template.items.length} {t('inventory:templates.items', 'ingredientes')}
</p>
</div>
</div>
</button>
))}
</div>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 border-b border-[var(--border-color)] overflow-x-auto scrollbar-hide -mx-2 px-2">
<button
onClick={() => setActiveFilter('all')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative whitespace-nowrap text-sm md:text-base ${
activeFilter === 'all'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{t('inventory:filter.all', 'Todos')} ({counts.all})
</button>
<button
onClick={() => setActiveFilter('finished_products')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'finished_products'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<ShoppingBag className="w-5 h-5" />
<span className="hidden sm:inline">{t('inventory:filter.finished_products', 'Productos Terminados')}</span>
<span className="sm:hidden">{t('inventory:filter.finished_products_short', 'Productos')}</span> ({counts.finished_products})
</button>
<button
onClick={() => setActiveFilter('ingredients')}
className={`px-3 md:px-4 py-3 font-medium transition-colors relative flex items-center gap-2 whitespace-nowrap text-sm md:text-base ${
activeFilter === 'ingredients'
? 'text-[var(--color-primary)] border-b-2 border-[var(--color-primary)] -mb-px'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
<Package className="w-5 h-5" />
{t('inventory:filter.ingredients', 'Ingredientes')} ({counts.ingredients})
</button>
</div>
{/* Inventory List */}
<div className="space-y-3">
{filteredItems.length === 0 && (
<div className="text-center py-8 text-[var(--text-secondary)]">
{activeFilter === 'all'
? t('inventory:empty_state', 'No hay productos. Agrega uno para comenzar.')
: t('inventory:no_results', 'No hay productos de este tipo.')}
</div>
)}
{filteredItems.map((item) => (
<React.Fragment key={item.id}>
{/* Item Card */}
<div className="p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium text-[var(--text-primary)] truncate">{item.name}</h5>
{/* Product Type Badge */}
<span className={`text-xs px-2 py-0.5 rounded-full ${
item.product_type === ProductType.FINISHED_PRODUCT
? 'bg-green-100 text-green-800'
: 'bg-blue-100 text-blue-800'
}`}>
{item.product_type === ProductType.FINISHED_PRODUCT ? (
<span className="flex items-center gap-1">
<ShoppingBag className="w-3 h-3" />
{t('inventory:type.finished_product', 'Producto')}
</span>
) : (
<span className="flex items-center gap-1">
<Package className="w-3 h-3" />
{t('inventory:type.ingredient', 'Ingrediente')}
</span>
)}
</span>
{/* AI Suggested Badge */}
{item.isSuggested && item.confidence_score && (
<span className="px-2 py-0.5 rounded text-xs bg-purple-100 text-purple-800">
IA {Math.round(item.confidence_score * 100)}%
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
<span>{item.product_type === ProductType.INGREDIENT ? t(`inventory:enums.ingredient_category.${item.category}`, item.category) : t(`inventory:enums.product_category.${item.category}`, item.category)}</span>
<span></span>
<span>{t(`inventory:enums.unit_of_measure.${item.unit_of_measure}`, item.unit_of_measure)}</span>
{item.sales_data && (
<>
<span></span>
<span>{t('inventory:sales_avg', 'Ventas')}: {item.sales_data.average_daily_sales.toFixed(1)}/día</span>
</>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-2 md:ml-4">
<button
onClick={() => handleEdit(item)}
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('common:edit', 'Editar')}
aria-label={t('common:edit', 'Editar')}
>
<Edit2 className="w-5 h-5 md:w-4 md:h-4" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-2 md:p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
title={t('common:delete', 'Eliminar')}
aria-label={t('common:delete', 'Eliminar')}
>
<Trash2 className="w-5 h-5 md:w-4 md:h-4" />
</button>
</div>
</div>
</div>
{/* Inline Edit Form - appears right below the card being edited */}
{editingId === item.id && (
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)] ml-0 md:ml-4 mt-2">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-[var(--text-primary)]">
{t('inventory:edit_item', 'Editar Producto')}
</h3>
<button
type="button"
onClick={handleCancel}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancelar')}
</button>
</div>
<div className="space-y-4">
{/* Product Type Selector */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
{t('inventory:product_type', 'Tipo de Producto')} *
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.ingredient', 'Ingrediente')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
</div>
</button>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.finished_product', 'Producto Terminado')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
</div>
</button>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:name', 'Nombre')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
/>
{formErrors.name && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:category', 'Categoría')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
<option value="">{t('common:select', 'Seleccionar...')}</option>
{getCategoryOptions(formData.product_type).map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{formErrors.category && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
)}
</div>
{/* Unit of Measure */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
</label>
<select
value={formData.unit_of_measure}
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
{unitOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Form Actions */}
<div className="flex justify-end pt-2">
<Button onClick={handleSave}>
<CheckCircle2 className="w-4 h-4 mr-2" />
{t('common:save', 'Guardar')}
</Button>
</div>
</div>
</div>
)}
</React.Fragment>
))}
</div>
{/* Add Button - hidden when adding or editing */}
{!isAdding && !editingId && (
<button
onClick={handleAdd}
className="w-full border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg p-4 md:p-4 hover:border-[var(--color-primary)]/50 transition-colors flex items-center justify-center gap-2 text-[var(--color-primary)] min-h-[44px] font-medium"
>
<Plus className="w-5 h-5" />
<span className="text-sm md:text-base">{t('inventory:add_item', 'Agregar Producto')}</span>
</button>
)}
{/* Add New Item Form - only shown when adding (not editing) */}
{isAdding && !editingId && (
<div className="border-2 border-[var(--color-primary)] rounded-lg p-3 md:p-4 bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-[var(--text-primary)]">
{t('inventory:add_item', 'Agregar Producto')}
</h3>
<button
type="button"
onClick={handleCancel}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancelar')}
</button>
</div>
<div className="space-y-4">
{/* Product Type Selector */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-3">
{t('inventory:product_type', 'Tipo de Producto')} *
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.INGREDIENT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.INGREDIENT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.INGREDIENT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<Package className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.ingredient', 'Ingrediente')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.ingredient_desc', 'Materias primas para producir')}
</div>
</button>
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, product_type: ProductType.FINISHED_PRODUCT, category: '' }))}
className={`relative p-3 sm:p-4 border-2 rounded-lg text-left transition-all ${
formData.product_type === ProductType.FINISHED_PRODUCT
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/30 shadow-lg ring-2 ring-[var(--color-primary)]/50'
: 'border-[var(--border-color)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]'
}`}
>
{formData.product_type === ProductType.FINISHED_PRODUCT && (
<CheckCircle2 className="absolute top-2 right-2 w-5 h-5 text-[var(--color-primary)]" />
)}
<ShoppingBag className="w-6 h-6 mb-2 text-[var(--color-primary)]" />
<div className="font-medium text-[var(--text-primary)]">
{t('inventory:type.finished_product', 'Producto Terminado')}
</div>
<div className="text-xs text-[var(--text-secondary)] mt-1">
{t('inventory:type.finished_product_desc', 'Productos que vendes')}
</div>
</button>
</div>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:name', 'Nombre')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
placeholder={t('inventory:name_placeholder', 'Ej: Harina de trigo')}
/>
{formErrors.name && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.name}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:category', 'Categoría')} *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
<option value="">{t('common:select', 'Seleccionar...')}</option>
{getCategoryOptions(formData.product_type).map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
{formErrors.category && (
<p className="text-xs text-[var(--color-danger)] mt-1">{formErrors.category}</p>
)}
</div>
{/* Unit of Measure */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('inventory:unit_of_measure', 'Unidad de Medida')} *
</label>
<select
value={formData.unit_of_measure}
onChange={(e) => setFormData(prev => ({ ...prev, unit_of_measure: e.target.value as UnitOfMeasure }))}
className="w-full px-3 py-2 border border-[var(--border-color)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
>
{unitOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Form Actions */}
<div className="flex justify-end pt-2">
<Button onClick={handleSave}>
<CheckCircle2 className="w-4 h-4 mr-2" />
{t('common:add', 'Agregar')}
</Button>
</div>
</div>
</div>
)}
{/* Submit Error */}
{formErrors.submit && (
<div className="bg-[var(--color-danger)]/10 border border-[var(--color-danger)]/20 rounded-lg p-4">
<p className="text-sm text-[var(--color-danger)]">{formErrors.submit}</p>
</div>
)}
{/* Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<p className="text-sm text-[var(--text-secondary)]">
{t('inventory:summary', 'Resumen')}: {counts.finished_products} {t('inventory:finished_products', 'productos terminados')}, {counts.ingredients} {t('inventory:ingredients_count', 'ingredientes')}
</p>
</div>
{/* Navigation */}
<div className="flex flex-col-reverse sm:flex-row justify-between gap-3 sm:gap-4 pt-6 border-t">
<Button
variant="outline"
onClick={onPrevious}
disabled={isSubmitting || isFirstStep}
className="w-full sm:w-auto"
>
<span className="hidden sm:inline">{t('common:back', '← Atrás')}</span>
<span className="sm:hidden">{t('common:back', 'Atrás')}</span>
</Button>
<Button
onClick={handleCompleteStep}
disabled={inventoryItems.length === 0 || isSubmitting}
className="w-full sm:w-auto sm:min-w-[200px]"
>
{isSubmitting
? t('common:saving', 'Guardando...')
: <>
<span className="hidden md:inline">{t('common:continue', 'Continuar')} ({inventoryItems.length} {t('common:items', 'productos')}) </span>
<span className="md:hidden">{t('common:continue', 'Continuar')} ({inventoryItems.length}) </span>
</>
}
</Button>
</div>
</div>
);
};

View File

@@ -157,13 +157,13 @@ export const ProductionProcessesStep: React.FC<ProductionProcessesStepProps> = (
};
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="max-w-4xl mx-auto p-4 md:p-6 space-y-4 md:space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<h1 className="text-2xl font-bold text-text-primary">
<h1 className="text-xl md:text-2xl font-bold text-text-primary px-2">
{t('onboarding:processes.title', 'Procesos de Producción')}
</h1>
<p className="text-text-secondary">
<p className="text-sm md:text-base text-text-secondary px-4">
{t(
'onboarding:processes.subtitle',
'Define los procesos que usas para transformar productos pre-elaborados en productos terminados'

View File

@@ -153,8 +153,8 @@ export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4 md:space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<Input
label="Nombre de la Panadería"
placeholder="Ingresa el nombre de tu panadería"

View File

@@ -4,6 +4,12 @@ export { default as DataSourceChoiceStep } from './DataSourceChoiceStep';
// Core Onboarding Steps
export { RegisterTenantStep } from './RegisterTenantStep';
// Sales Data & Inventory (REFACTORED - split from UploadSalesDataStep)
export { FileUploadStep } from './FileUploadStep';
export { InventoryReviewStep } from './InventoryReviewStep';
// Legacy (keep for now, will deprecate after testing)
export { UploadSalesDataStep } from './UploadSalesDataStep';
// AI-Assisted Path Steps

View File

@@ -1,372 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../../contexts/AuthContext';
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { StepProgress } from './components/StepProgress';
import { StepNavigation } from './components/StepNavigation';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import {
WelcomeStep,
SuppliersSetupStep,
InventorySetupStep,
RecipesSetupStep,
QualitySetupStep,
TeamSetupStep,
ReviewSetupStep,
CompletionStep
} from './steps';
// Step weights for weighted progress calculation
const STEP_WEIGHTS = {
'setup-welcome': 5, // 2 min (light)
'suppliers-setup': 10, // 5 min (moderate)
'inventory-items-setup': 20, // 10 min (heavy)
'recipes-setup': 20, // 10 min (heavy)
'quality-setup': 15, // 7 min (moderate)
'team-setup': 10, // 5 min (optional)
'setup-review': 5, // 2 min (light, informational)
'setup-completion': 5 // 2 min (light)
};
export interface SetupStepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<SetupStepProps>;
minRequired?: number; // Minimum items to proceed
isOptional?: boolean; // Can be skipped
estimatedMinutes?: number; // For UI display
weight: number; // For progress calculation
}
export interface SetupStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
onSkip?: () => void;
onUpdate?: (state: { itemsCount?: number; canContinue?: boolean }) => void;
isFirstStep: boolean;
isLastStep: boolean;
canContinue?: boolean;
}
export const SetupWizard: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useAuth();
// Define setup wizard steps (Steps 5-11 in overall onboarding)
const SETUP_STEPS: SetupStepConfig[] = [
{
id: 'setup-welcome',
title: t('setup_wizard:steps.welcome.title', 'Welcome & Setup Overview'),
description: t('setup_wizard:steps.welcome.description', 'Let\'s set up your bakery operations'),
component: WelcomeStep,
isOptional: true,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-welcome']
},
{
id: 'suppliers-setup',
title: t('setup_wizard:steps.suppliers.title', 'Add Suppliers'),
description: t('setup_wizard:steps.suppliers.description', 'Your ingredient and material providers'),
component: SuppliersSetupStep,
minRequired: 1,
isOptional: false,
estimatedMinutes: 5,
weight: STEP_WEIGHTS['suppliers-setup']
},
{
id: 'inventory-items-setup',
title: t('setup_wizard:steps.inventory.title', 'Set Up Inventory Items'),
description: t('setup_wizard:steps.inventory.description', 'Ingredients and materials you use'),
component: InventorySetupStep,
minRequired: 3,
isOptional: false,
estimatedMinutes: 10,
weight: STEP_WEIGHTS['inventory-items-setup']
},
{
id: 'recipes-setup',
title: t('setup_wizard:steps.recipes.title', 'Create Recipes'),
description: t('setup_wizard:steps.recipes.description', 'Your bakery\'s production formulas'),
component: RecipesSetupStep,
minRequired: 1,
isOptional: false,
estimatedMinutes: 10,
weight: STEP_WEIGHTS['recipes-setup']
},
{
id: 'quality-setup',
title: t('setup_wizard:steps.quality.title', 'Define Quality Standards'),
description: t('setup_wizard:steps.quality.description', 'Standards for consistent production'),
component: QualitySetupStep,
minRequired: 2,
isOptional: true,
estimatedMinutes: 7,
weight: STEP_WEIGHTS['quality-setup']
},
{
id: 'team-setup',
title: t('setup_wizard:steps.team.title', 'Add Team Members'),
description: t('setup_wizard:steps.team.description', 'Your bakery staff'),
component: TeamSetupStep,
minRequired: 0,
isOptional: true,
estimatedMinutes: 5,
weight: STEP_WEIGHTS['team-setup']
},
{
id: 'setup-review',
title: t('setup_wizard:steps.review.title', 'Review Your Setup'),
description: t('setup_wizard:steps.review.description', 'Confirm your configuration'),
component: ReviewSetupStep,
isOptional: false,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-review']
},
{
id: 'setup-completion',
title: t('setup_wizard:steps.completion.title', 'You\'re All Set!'),
description: t('setup_wizard:steps.completion.description', 'Your bakery system is ready'),
component: CompletionStep,
isOptional: false,
estimatedMinutes: 2,
weight: STEP_WEIGHTS['setup-completion']
}
];
// State management
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(false);
const [canContinue, setCanContinue] = useState(false);
// Handle updates from step components
const handleStepUpdate = (state: { itemsCount?: number; canContinue?: boolean }) => {
if (state.canContinue !== undefined) {
setCanContinue(state.canContinue);
}
};
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
// Calculate weighted progress percentage
const calculateProgress = (): number => {
if (!userProgress) return 0;
const totalWeight = Object.values(STEP_WEIGHTS).reduce((a, b) => a + b);
let completedWeight = 0;
// Add weight of fully completed steps
SETUP_STEPS.forEach((step, index) => {
if (index < currentStepIndex) {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (stepProgress?.completed) {
completedWeight += step.weight;
}
}
});
// Add 50% of current step weight (user is midway through)
const currentStep = SETUP_STEPS[currentStepIndex];
completedWeight += currentStep.weight * 0.5;
return Math.round((completedWeight / totalWeight) * 100);
};
const progressPercentage = calculateProgress();
// Initialize step index based on backend progress
useEffect(() => {
if (userProgress && !isInitialized) {
console.log('🔄 Initializing setup wizard progress:', userProgress);
// Find first incomplete step
let stepIndex = 0;
for (let i = 0; i < SETUP_STEPS.length; i++) {
const step = SETUP_STEPS[i];
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (!stepProgress?.completed && stepProgress?.status !== 'skipped') {
stepIndex = i;
console.log(`📍 Resuming at step: "${step.id}" (index ${i})`);
break;
}
}
// If all steps complete, go to last step
if (stepIndex === 0 && SETUP_STEPS.every(step => {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
return stepProgress?.completed || stepProgress?.status === 'skipped';
})) {
stepIndex = SETUP_STEPS.length - 1;
console.log('✅ All steps completed, going to completion step');
}
setCurrentStepIndex(stepIndex);
setIsInitialized(true);
}
}, [userProgress, isInitialized]);
const currentStep = SETUP_STEPS[currentStepIndex];
// Navigation handlers
const handleNext = () => {
if (currentStepIndex < SETUP_STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
setCanContinue(false); // Reset for next step
}
};
const handlePrevious = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
};
const handleSkip = async () => {
if (!user?.id || !currentStep.isOptional) return;
console.log(`⏭️ Skipping step: "${currentStep.id}"`);
try {
// Mark step as skipped (not completed)
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data: {
skipped: true,
skipped_at: new Date().toISOString()
}
});
console.log(`✅ Step "${currentStep.id}" marked as skipped`);
// Move to next step
handleNext();
} catch (error) {
console.error(`❌ Error skipping step "${currentStep.id}":`, error);
}
};
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
// Prevent concurrent mutations
if (markStepCompleted.isPending) {
console.warn(`⚠️ Step completion already in progress for "${currentStep.id}"`);
return;
}
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try {
// Mark step as completed in backend
await markStepCompleted.mutateAsync({
userId: user.id,
stepName: currentStep.id,
data: {
...data,
completed_at: new Date().toISOString()
}
});
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
// Handle completion step navigation
if (currentStep.id === 'setup-completion') {
console.log('🎉 Setup wizard completed! Navigating to dashboard...');
navigate('/app/dashboard');
} else {
// Auto-advance to next step
handleNext();
}
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
alert(`${t('setup_wizard:errors.step_failed', 'Error completing step')} "${currentStep.title}": ${errorMessage}`);
}
};
// Show loading state while initializing
if (isLoadingProgress || !isInitialized) {
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<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)]">
{t('common:loading', 'Loading your setup progress...')}
</p>
</div>
</CardBody>
</Card>
</div>
);
}
const StepComponent = currentStep.component;
return (
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 space-y-6">
{/* Progress Header */}
<StepProgress
steps={SETUP_STEPS}
currentStepIndex={currentStepIndex}
progressPercentage={progressPercentage}
userProgress={userProgress}
/>
{/* Step Content */}
<Card shadow="lg" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="w-6 h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold">
{currentStepIndex + 1}
</div>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)] text-sm">
{currentStep.description}
</p>
</div>
{currentStep.estimatedMinutes && (
<div className="hidden sm:block text-sm text-[var(--text-tertiary)]">
~{currentStep.estimatedMinutes} min
</div>
)}
</div>
</CardHeader>
<CardBody padding="lg">
<StepComponent
onNext={handleNext}
onPrevious={handlePrevious}
onComplete={handleStepComplete}
onSkip={handleSkip}
onUpdate={handleStepUpdate}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === SETUP_STEPS.length - 1}
canContinue={canContinue}
/>
</CardBody>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
export { SetupWizard } from './SetupWizard';
export type { SetupStepConfig, SetupStepProps } from './SetupWizard';
// SetupWizard.tsx has been deleted - setup is now integrated into UnifiedOnboardingWizard
// Individual setup steps are still used by UnifiedOnboardingWizard
export * from './steps';
export * from './components';

View File

@@ -14,8 +14,8 @@ const OnboardingPage: React.FC = () => {
variant: "minimal"
}}
>
<div className="min-h-screen bg-[var(--bg-primary)] py-8">
<div className="container mx-auto px-4">
<div className="min-h-screen bg-[var(--bg-primary)] py-4 md:py-8">
<div className="container mx-auto px-2 sm:px-4">
<UnifiedOnboardingWizard />
</div>
</div>

View File

@@ -1,18 +0,0 @@
import React from 'react';
import { SetupWizard } from '../../components/domain/setup-wizard';
/**
* Setup Page - Wrapper for the Setup Wizard
* This page is accessed after completing the initial onboarding
* and guides users through setting up their bakery operations
* (suppliers, inventory, recipes, quality standards, team)
*/
const SetupPage: React.FC = () => {
return (
<div className="min-h-screen bg-[var(--bg-primary)]">
<SetupWizard />
</div>
);
};
export default SetupPage;

View File

@@ -57,9 +57,8 @@ const ModelsConfigPage = React.lazy(() => import('../pages/app/database/models/M
const QualityTemplatesPage = React.lazy(() => import('../pages/app/database/quality-templates/QualityTemplatesPage'));
const SustainabilityPage = React.lazy(() => import('../pages/app/database/sustainability/SustainabilityPage'));
// Onboarding & Setup pages
// Onboarding page (Setup is now integrated into UnifiedOnboardingWizard)
const OnboardingPage = React.lazy(() => import('../pages/onboarding/OnboardingPage'));
const SetupPage = React.lazy(() => import('../pages/setup/SetupPage'));
export const AppRouter: React.FC = () => {
return (
@@ -389,17 +388,7 @@ export const AppRouter: React.FC = () => {
}
/>
{/* Setup Wizard Route - Protected with AppShell */}
<Route
path="/app/setup"
element={
<ProtectedRoute>
<AppShell>
<SetupPage />
</AppShell>
</ProtectedRoute>
}
/>
{/* Setup is now integrated into UnifiedOnboardingWizard */}
{/* Default redirects */}
<Route path="/app/*" element={<Navigate to="/app/dashboard" replace />} />

View File

@@ -165,9 +165,8 @@ export const ROUTES = {
HELP_SUPPORT: '/help/support',
HELP_FEEDBACK: '/help/feedback',
// Onboarding & Setup
// Onboarding (Setup is now integrated into UnifiedOnboardingWizard)
ONBOARDING: '/app/onboarding',
SETUP: '/app/setup',
// Error pages
NOT_FOUND: '/404',
@@ -575,22 +574,7 @@ export const routesConfig: RouteConfig[] = [
},
},
// Setup Wizard - Bakery operations setup (post-onboarding)
{
path: '/app/setup',
name: 'Setup',
component: 'SetupPage',
title: 'Configurar Operaciones',
description: 'Configure suppliers, inventory, recipes, and quality standards',
icon: 'settings',
requiresAuth: true,
showInNavigation: false,
meta: {
hideHeader: false, // Show header for easy navigation
hideSidebar: false, // Show sidebar for context
fullScreen: false,
},
},
// Setup is now integrated into UnifiedOnboardingWizard - route removed
// Error pages