IMPORVE ONBOARDING STEPS
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user