Simplify the onboardinf flow components

This commit is contained in:
Urtzi Alfaro
2025-09-08 17:19:00 +02:00
parent 201817a1be
commit 2e1e696cb5
32 changed files with 1431 additions and 6366 deletions

View File

@@ -1,384 +1,185 @@
import React, { useState, useCallback } from 'react';
import { Card, Button } from '../../ui';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../../ui/Button';
import { useAuth } from '../../../contexts/AuthContext';
import { useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { useTenantActions } from '../../../stores/tenant.store';
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import {
RegisterTenantStep,
UploadSalesDataStep,
MLTrainingStep,
CompletionStep
} from './steps';
export interface OnboardingStep {
interface StepConfig {
id: string;
title: string;
description: string;
component: React.ComponentType<OnboardingStepProps>;
isCompleted?: boolean;
isRequired?: boolean;
validation?: (data: any) => string | null;
component: React.ComponentType<StepProps>;
}
export interface OnboardingStepProps {
data: any;
onDataChange: (data: any) => void;
interface StepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
interface OnboardingWizardProps {
steps: OnboardingStep[];
currentStep: number;
data: any;
onStepChange: (stepIndex: number, stepData: any) => void;
onNext: () => Promise<boolean> | boolean;
onPrevious: () => boolean;
onComplete: (data: any) => Promise<void> | void;
onGoToStep: (stepIndex: number) => boolean;
onExit?: () => void;
className?: string;
}
const STEPS: StepConfig[] = [
{
id: 'setup',
title: 'Registrar Panadería',
description: 'Configura la información de tu panadería',
component: RegisterTenantStep,
},
{
id: 'smart-inventory-setup',
title: 'Configurar Inventario',
description: 'Sube datos de ventas y configura tu inventario inicial',
component: UploadSalesDataStep,
},
{
id: 'ml-training',
title: 'Entrenamiento IA',
description: 'Entrena tu modelo de inteligencia artificial',
component: MLTrainingStep,
},
{
id: 'completion',
title: 'Configuración Completa',
description: 'Bienvenido a tu sistema de gestión de panadería',
component: CompletionStep,
},
];
export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
steps,
currentStep: currentStepIndex,
data: stepData,
onStepChange,
onNext,
onPrevious,
onComplete,
onGoToStep,
onExit,
className = '',
}) => {
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
export const OnboardingWizard: React.FC = () => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const navigate = useNavigate();
const { user } = useAuth();
// Initialize tenant data for authenticated users
useTenantInitializer();
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
const currentStep = steps[currentStepIndex];
const currentStep = STEPS[currentStepIndex];
const updateStepData = useCallback((stepId: string, data: any) => {
onStepChange(currentStepIndex, { ...stepData, ...data });
// Clear validation error for this step
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[stepId];
return newErrors;
});
}, [currentStepIndex, stepData, onStepChange]);
const validateCurrentStep = useCallback(() => {
const step = currentStep;
const data = stepData || {};
if (step.validation) {
const error = step.validation(data);
if (error) {
setValidationErrors(prev => ({
...prev,
[step.id]: error
}));
return false;
}
const handlePrevious = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
// Mark step as completed
setCompletedSteps(prev => new Set(prev).add(step.id));
return true;
}, [currentStep, stepData]);
const goToNextStep = useCallback(async () => {
if (validateCurrentStep()) {
const result = onNext();
if (result instanceof Promise) {
await result;
}
}
}, [validateCurrentStep, onNext]);
const goToPreviousStep = useCallback(() => {
onPrevious();
}, [onPrevious]);
const goToStep = useCallback((stepIndex: number) => {
onGoToStep(stepIndex);
}, [onGoToStep]);
const calculateProgress = () => {
return (completedSteps.size / steps.length) * 100;
};
const renderStepIndicator = () => (
<div className="mb-8" role="navigation" aria-label="Progreso del proceso de configuración">
<div className="max-w-5xl mx-auto px-4">
{/* Progress summary */}
<div className="text-center mb-6">
<div className="inline-flex items-center space-x-3 bg-[var(--bg-secondary)]/50 rounded-full px-6 py-3 backdrop-blur-sm">
<span className="text-sm text-[var(--text-secondary)]">Paso</span>
<span className="font-bold text-[var(--color-primary)] text-lg">
{currentStepIndex + 1}
</span>
<span className="text-sm text-[var(--text-secondary)]">de</span>
<span className="font-bold text-[var(--text-primary)] text-lg">
{steps.length}
</span>
<div className="w-px h-6 bg-[var(--border-secondary)]" />
<span className="text-sm font-medium text-[var(--color-primary)]">
{Math.round(calculateProgress())}% completado
</span>
</div>
</div>
const handleStepComplete = async (data?: any) => {
try {
// Special handling for setup step - set the created tenant in tenant store
if (currentStep.id === 'setup' && data?.tenant) {
setCurrentTenant(data.tenant);
}
{/* Progress connections between steps */}
<div className="relative mb-8" role="progressbar"
aria-valuenow={completedSteps.size}
aria-valuemin={0}
aria-valuemax={steps.length}
aria-label={`${completedSteps.size} de ${steps.length} pasos completados`}>
{/* Step circles */}
<div className="relative">
{/* Desktop view */}
<div className="hidden md:flex justify-between items-center">
{steps.map((step, index) => {
const isCompleted = completedSteps.has(step.id);
const isCurrent = index === currentStepIndex;
const hasError = validationErrors[step.id];
const isAccessible = index <= currentStepIndex + 1;
const nextStepCompleted = index < steps.length - 1 && completedSteps.has(steps[index + 1].id);
// Mark step as completed in backend
await markStepCompleted.mutateAsync({
userId: user?.id || '',
stepName: currentStep.id,
data
});
return (
<React.Fragment key={step.id}>
<div className="flex flex-col items-center">
<button
onClick={() => isAccessible ? goToStep(index) : null}
disabled={!isAccessible}
className={`
relative flex items-center justify-center w-12 h-12 rounded-full text-sm font-bold z-10
transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-opacity-50
${
isCompleted
? 'bg-[var(--color-success)] text-white shadow-lg hover:bg-[var(--color-success)]/90 focus:ring-[var(--color-success)]'
: isCurrent
? hasError
? 'bg-[var(--color-error)] text-white shadow-lg animate-pulse focus:ring-[var(--color-error)]'
: 'bg-[var(--color-primary)] text-white shadow-xl scale-110 ring-4 ring-[var(--color-primary)]/20 focus:ring-[var(--color-primary)]'
: isAccessible
? 'bg-[var(--bg-primary)] border-2 border-[var(--border-secondary)] text-[var(--text-secondary)] shadow-sm hover:border-[var(--color-primary)]/50 hover:shadow-md focus:ring-[var(--color-primary)]'
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-tertiary)] text-[var(--text-tertiary)] cursor-not-allowed opacity-50'
}
`}
>
{isCompleted ? (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<span>{index + 1}</span>
)}
</button>
{/* Step label */}
<div className={`mt-3 text-center max-w-20 ${
isCurrent
? 'text-[var(--color-primary)] font-semibold'
: isCompleted
? 'text-[var(--color-success)] font-medium'
: 'text-[var(--text-secondary)]'
}`}>
<div className="text-xs leading-tight">
{step.title.replace(/🏢|📊|🤖|📋|⚙️|🏪|🧠|🎉/g, '').trim()}
</div>
</div>
{/* Error indicator */}
{hasError && (
<div className="absolute -top-1 -right-1 w-4 h-4 bg-[var(--color-error)] rounded-full flex items-center justify-center z-20">
<span className="text-white text-xs">!</span>
</div>
)}
</div>
{/* Connection line to next step */}
{index < steps.length - 1 && (
<div className="flex items-center flex-1">
<div className="h-1 w-full mx-4 rounded-full transition-all duration-500">
<div className={`h-full rounded-full ${
isCompleted ? 'bg-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
}`} />
</div>
</div>
)}
</React.Fragment>
);
})}
</div>
{/* Mobile view - simplified horizontal scroll */}
<div className="md:hidden">
<div className="flex items-center overflow-x-auto pb-4 px-4">
{steps.map((step, index) => {
const isCompleted = completedSteps.has(step.id);
const isCurrent = index === currentStepIndex;
const hasError = validationErrors[step.id];
const isAccessible = index <= currentStepIndex + 1;
return (
<React.Fragment key={step.id}>
<div className="flex-shrink-0 flex flex-col items-center">
<button
onClick={() => isAccessible ? goToStep(index) : null}
disabled={!isAccessible}
className={`
relative flex items-center justify-center w-10 h-10 rounded-full text-xs font-bold z-10
transition-all duration-300 focus:outline-none focus:ring-3 focus:ring-opacity-50
${
isCompleted
? 'bg-[var(--color-success)] text-white shadow-lg focus:ring-[var(--color-success)]'
: isCurrent
? hasError
? 'bg-[var(--color-error)] text-white shadow-lg focus:ring-[var(--color-error)]'
: 'bg-[var(--color-primary)] text-white shadow-lg ring-3 ring-[var(--color-primary)]/20 focus:ring-[var(--color-primary)]'
: isAccessible
? 'bg-[var(--bg-primary)] border-2 border-[var(--border-secondary)] text-[var(--text-secondary)] focus:ring-[var(--color-primary)]'
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-tertiary)] text-[var(--text-tertiary)] cursor-not-allowed opacity-50'
}
`}
>
{isCompleted ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<span>{index + 1}</span>
)}
</button>
{/* Step label for mobile */}
<div className={`mt-2 text-center min-w-16 ${
isCurrent
? 'text-[var(--color-primary)] font-semibold'
: isCompleted
? 'text-[var(--color-success)] font-medium'
: 'text-[var(--text-secondary)]'
}`}>
<div className="text-xs leading-tight">
{step.title.replace(/🏢|📊|🤖|📋|⚙️|🏪|🧠|🎉/g, '').trim()}
</div>
</div>
</div>
{/* Connection line for mobile */}
{index < steps.length - 1 && (
<div className="flex-shrink-0 w-8 h-1 mx-2 rounded-full transition-all duration-500">
<div className={`w-full h-full rounded-full ${
isCompleted ? 'bg-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
}`} />
</div>
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
if (!currentStep) {
return (
<Card className={`p-8 text-center ${className}`}>
<p className="text-[var(--text-tertiary)]">No hay pasos de onboarding configurados.</p>
</Card>
);
}
if (currentStep.id === 'completion') {
navigate('/app');
} else {
// Auto-advance to next step after successful completion
if (currentStepIndex < STEPS.length - 1) {
setCurrentStepIndex(currentStepIndex + 1);
}
}
} catch (error) {
console.error('Error marking step as completed:', error);
}
};
const StepComponent = currentStep.component;
return (
<div className={`min-h-screen bg-[var(--bg-primary)] ${className}`}>
<div className="max-w-4xl mx-auto px-4 py-8">
{/* Header with clean design */}
<div className="text-center mb-6">
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-2">
Configuración inicial
<div className="max-w-4xl mx-auto">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
Bienvenido a Bakery IA
</h1>
<p className="text-[var(--text-secondary)] mb-4">
Completa estos pasos para comenzar a usar la plataforma
</p>
<span className="text-sm text-[var(--text-secondary)]">
Paso {currentStepIndex + 1} de {STEPS.length}
</span>
</div>
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
/>
</div>
{onExit && (
<button
onClick={onExit}
className="absolute top-8 right-8 text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] text-2xl"
<div className="flex justify-between mt-4">
{STEPS.map((step, index) => (
<div
key={step.id}
className={`flex-1 text-center px-2 ${
index <= currentStepIndex
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
</button>
)}
</div>
{renderStepIndicator()}
{/* Main Content - Single clean card */}
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-2xl shadow-xl overflow-hidden">
{/* Step header */}
<div className="bg-[var(--bg-secondary)] px-8 py-6 border-b border-[var(--border-secondary)]">
<h2 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)]">
{currentStep.description}
</p>
</div>
{/* Step content */}
<div className="px-8 py-8">
<StepComponent
data={stepData}
onDataChange={(data) => {
updateStepData(currentStep.id, data);
}}
onNext={goToNextStep}
onPrevious={goToPreviousStep}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === steps.length - 1}
/>
</div>
{/* Navigation footer */}
<div className="bg-[var(--bg-secondary)] px-8 py-6 border-t border-[var(--border-secondary)]">
<div className="flex justify-between items-center">
<Button
variant="outline"
onClick={goToPreviousStep}
disabled={currentStepIndex === 0}
className="px-6"
>
Anterior
</Button>
<Button
variant="primary"
onClick={async () => {
if (currentStepIndex === steps.length - 1) {
await onComplete(stepData);
} else {
await goToNextStep();
}
}}
disabled={
(currentStep.validation && currentStep.validation(stepData || {}))
}
className="px-8"
>
{currentStepIndex === steps.length - 1 ? '🎉 Finalizar' : 'Siguiente →'}
</Button>
<div className={`text-xs font-medium mb-1`}>
{step.title}
</div>
<div className="text-xs opacity-75">
{step.description}
</div>
</div>
</div>
))}
</div>
</div>
{/* Step Content */}
<div className="bg-[var(--bg-primary)] rounded-lg shadow-lg p-8">
<div className="mb-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)]">
{currentStep.description}
</p>
</div>
<StepComponent
onNext={() => {}} // No-op - steps must use onComplete instead
onPrevious={handlePrevious}
onComplete={handleStepComplete}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === STEPS.length - 1}
/>
</div>
{/* Navigation */}
<div className="flex justify-between mt-8">
<Button
variant="outline"
onClick={handlePrevious}
disabled={currentStepIndex === 0}
>
Anterior
</Button>
<div className="text-sm text-[var(--text-tertiary)] self-center">
Puedes pausar y reanudar este proceso en cualquier momento
</div>
{/* No skip button - all steps are required */}
<div></div>
</div>
</div>
);
};
export default OnboardingWizard;
};

View File

@@ -1,12 +1 @@
// Onboarding domain components
export { default as OnboardingWizard } from './OnboardingWizard';
// Individual step components
export { BakerySetupStep } from './steps/BakerySetupStep';
export { SmartInventorySetupStep } from './steps/SmartInventorySetupStep';
export { SuppliersStep } from './steps/SuppliersStep';
export { MLTrainingStep } from './steps/MLTrainingStep';
export { CompletionStep } from './steps/CompletionStep';
// Re-export types from wizard
export type { OnboardingStep, OnboardingStepProps } from './OnboardingWizard';
export { OnboardingWizard } from './OnboardingWizard';

View File

@@ -1,329 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Store, MapPin, Phone, Mail, Hash, Building } from 'lucide-react';
import { Button, Card, Input } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { useAuthUser } from '../../../../stores/auth.store';
import { useOnboarding } from '../../../../hooks/business/onboarding';
// Backend-compatible bakery setup interface
interface BakerySetupData {
name: string;
business_type: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
business_model: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
address: string;
city: string;
postal_code: string;
phone: string;
email?: string;
description?: string;
}
export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
data,
onDataChange,
onNext,
onPrevious,
isFirstStep,
isLastStep
}) => {
const user = useAuthUser();
const userId = user?.id;
// Business onboarding hooks
const {
updateStepData,
tenantCreation,
isLoading,
error,
clearError
} = useOnboarding();
const [formData, setFormData] = useState<BakerySetupData>({
name: data.bakery?.name || '',
business_type: data.bakery?.business_type || 'bakery',
business_model: data.bakery?.business_model || 'individual_bakery',
address: data.bakery?.address || '',
city: data.bakery?.city || 'Madrid',
postal_code: data.bakery?.postal_code || '',
phone: data.bakery?.phone || '',
email: data.bakery?.email || '',
description: data.bakery?.description || ''
});
const bakeryModels = [
{
value: 'individual_bakery',
label: 'Panadería Individual',
description: 'Producción propia tradicional con recetas artesanales',
icon: '🥖'
},
{
value: 'central_baker_satellite',
label: 'Panadería Central con Satélites',
description: 'Producción centralizada con múltiples puntos de venta',
icon: '🏭'
},
{
value: 'retail_bakery',
label: 'Panadería Retail',
description: 'Punto de venta que compra productos terminados',
icon: '🏪'
},
{
value: 'hybrid_bakery',
label: 'Modelo Híbrido',
description: 'Combina producción propia con productos externos',
icon: '🔄'
}
];
const lastFormDataRef = useRef(formData);
useEffect(() => {
// Only update if formData actually changed and is valid
if (JSON.stringify(formData) !== JSON.stringify(lastFormDataRef.current)) {
lastFormDataRef.current = formData;
// Update parent data when form changes
const bakeryData = {
...formData,
tenant_id: data.bakery?.tenant_id
};
onDataChange({ bakery: bakeryData });
}
}, [formData, onDataChange, data.bakery?.tenant_id]);
// Separate effect for hook updates to avoid circular dependencies
useEffect(() => {
const timeoutId = setTimeout(() => {
if (userId && Object.values(formData).some(value => value.trim() !== '')) {
updateStepData('setup', { bakery: formData });
}
}, 1000);
return () => clearTimeout(timeoutId);
}, [formData, userId, updateStepData]);
const handleInputChange = (field: keyof BakerySetupData, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
// Validate form data for completion
const isFormValid = () => {
return !!(
formData.name &&
formData.address &&
formData.city &&
formData.postal_code &&
formData.phone &&
formData.business_type &&
formData.business_model
);
};
return (
<div className="space-y-8">
{/* Form */}
<div className="space-y-8">
{/* Basic Info */}
<div className="space-y-6">
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
Nombre de la Panadería *
</label>
<Input
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Ej: Panadería Artesanal El Buen Pan"
className="w-full text-lg py-3"
/>
</div>
{/* Business Model Selection */}
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
Modelo de Negocio *
</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{bakeryModels.map((model) => (
<label
key={model.value}
className={`
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
${formData.business_model === model.value
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
}
`}
>
<input
type="radio"
name="businessModel"
value={model.value}
checked={formData.business_model === model.value}
onChange={(e) => handleInputChange('business_model', e.target.value as BakerySetupData['business_model'])}
className="sr-only"
/>
<div className="flex items-center space-x-3 w-full">
<span className="text-2xl">{model.icon}</span>
<div>
<h4 className="font-medium text-[var(--text-primary)]">
{model.label}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{model.description}
</p>
</div>
</div>
</label>
))}
</div>
</div>
{/* Location and Contact - Backend compatible */}
<div className="space-y-4">
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
Dirección *
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
<Input
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
placeholder="Dirección completa de tu panadería"
className="w-full pl-12 py-3"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Ciudad *
</label>
<div className="relative">
<Building className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
placeholder="Madrid"
className="w-full pl-10"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Código Postal *
</label>
<div className="relative">
<Hash className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.postal_code}
onChange={(e) => handleInputChange('postal_code', e.target.value)}
placeholder="28001"
pattern="[0-9]{5}"
className="w-full pl-10"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Teléfono *
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="+34 600 123 456"
type="tel"
className="w-full pl-10"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Email (opcional)
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
<Input
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="contacto@panaderia.com"
className="w-full pl-10"
/>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Show loading state for tenant creation */}
{tenantCreation.isLoading && (
<div className="text-center p-4 bg-blue-50 rounded-lg">
<div className="animate-spin w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mx-auto mb-2" />
<p className="text-blue-600 text-sm">
Configurando panadería...
</p>
</div>
)}
{/* Show loading state when creating tenant */}
{data.bakery?.isCreating && (
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-[var(--color-primary)] font-medium">
Creando tu espacio de trabajo...
</p>
</div>
)}
{/* Show error state for business onboarding operations */}
{error && (
<div className="text-center p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 font-medium mb-2">
Error al configurar panadería
</p>
<p className="text-red-500 text-sm">
{error}
</p>
<button
onClick={clearError}
className="mt-2 text-sm text-red-600 underline hover:no-underline"
>
Ocultar error
</button>
</div>
)}
{/* Show error state if tenant creation fails */}
{data.bakery?.creationError && (
<div className="text-center p-6 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 font-medium mb-2">
Error al crear el espacio de trabajo
</p>
<p className="text-red-500 text-sm">
{data.bakery.creationError}
</p>
</div>
)}
</div>
);
};

View File

@@ -1,477 +1,157 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { useModal } from '../../../../hooks/ui/useModal';
import { useAuthUser } from '../../../../stores/auth.store';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
interface CompletionStats {
totalProducts: number;
inventoryItems: number;
suppliersConfigured: number;
mlModelAccuracy: number;
estimatedTimeSaved: string;
completionScore: number;
salesImported: boolean;
salesImportRecords: number;
interface CompletionStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
export const CompletionStep: React.FC<OnboardingStepProps> = ({
data,
onDataChange,
onNext,
onPrevious,
isFirstStep,
isLastStep
export const CompletionStep: React.FC<CompletionStepProps> = ({
onComplete
}) => {
const user = useAuthUser();
const createAlert = (alert: any) => {
console.log('Alert:', alert);
const navigate = useNavigate();
const currentTenant = useCurrentTenant();
const handleGetStarted = () => {
onComplete({ redirectTo: '/app' });
navigate('/app');
};
const certificateModal = useModal();
const demoModal = useModal();
const shareModal = useModal();
const [showConfetti, setShowConfetti] = useState(false);
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
// Handle final sales import
const handleFinalSalesImport = async () => {
if (!user?.tenant_id || !data.files?.salesData || !data.inventoryMapping) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación final',
message: 'Faltan datos necesarios para importar las ventas.',
source: 'onboarding'
});
return;
}
try {
// Sales data should already be imported during DataProcessingStep
// Just create inventory items from approved suggestions
// TODO: Implement inventory creation from suggestions
const result = { success: true, successful_imports: 0 };
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Importación completada',
message: `Se importaron ${result.successful_imports} registros de ventas exitosamente.`,
source: 'onboarding'
});
// Update completion stats
const updatedStats = {
...completionStats!,
salesImported: true,
salesImportRecords: result.successful_imports || 0
};
setCompletionStats(updatedStats);
onDataChange({
...data,
completionStats: updatedStats,
salesImportResult: result,
finalImportCompleted: true
});
} catch (error) {
console.error('Sales import error:', error);
const errorMessage = error instanceof Error ? error.message : 'Error al importar datos de ventas';
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación',
message: errorMessage,
source: 'onboarding'
});
}
};
useEffect(() => {
// Show confetti animation
setShowConfetti(true);
const timer = setTimeout(() => setShowConfetti(false), 3000);
// Calculate completion stats
const stats: CompletionStats = {
totalProducts: data.detectedProducts?.filter((p: any) => p.status === 'approved').length || 0,
inventoryItems: data.inventoryItems?.length || 0,
suppliersConfigured: data.suppliers?.length || 0,
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
estimatedTimeSaved: '15-20 horas',
completionScore: calculateCompletionScore(),
salesImported: data.finalImportCompleted || false,
salesImportRecords: data.salesImportResult?.successful_imports || 0
};
setCompletionStats(stats);
// Update parent data
onDataChange({
...data,
completionStats: stats,
onboardingCompleted: true,
completedAt: new Date().toISOString()
});
// Trigger final sales import if not already done
if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) {
handleFinalSalesImport();
}
return () => clearTimeout(timer);
}, []);
const calculateCompletionScore = () => {
let score = 0;
// Base score for completing setup
if (data.bakery?.tenant_id) score += 20;
// Data upload and analysis
if (data.validation?.is_valid) score += 15;
if (data.analysisStatus === 'completed') score += 15;
// Product review
const approvedProducts = data.detectedProducts?.filter((p: any) => p.status === 'approved').length || 0;
if (approvedProducts > 0) score += 20;
// Inventory setup
if (data.inventoryItems?.length > 0) score += 15;
// ML training
if (data.trainingStatus === 'completed') score += 15;
return Math.min(score, 100);
};
const generateCertificate = () => {
// Mock certificate generation
const certificateData = {
bakeryName: data.bakery?.name || 'Tu Panadería',
completionDate: new Date().toLocaleDateString('es-ES'),
score: completionStats?.completionScore || 0,
features: [
'Configuración de Tenant Multi-inquilino',
'Análisis de Datos con IA',
'Gestión de Inventario Inteligente',
'Entrenamiento de Modelo ML',
'Integración de Proveedores'
]
};
console.log('Generating certificate:', certificateData);
certificateModal.openModal({
title: '🎓 Certificado Generado',
message: `Certificado generado para ${certificateData.bakeryName}\nPuntuación: ${certificateData.score}/100`
});
};
const scheduleDemo = () => {
// Mock demo scheduling
demoModal.openModal({
title: '📅 Demo Agendado',
message: 'Te contactaremos pronto para agendar una demostración personalizada de las funcionalidades avanzadas.'
});
};
const shareSuccess = () => {
// Mock social sharing
const shareText = `¡Acabo de completar la configuración de mi panadería inteligente con IA! 🥖🤖 Puntuación: ${completionStats?.completionScore}/100`;
navigator.clipboard.writeText(shareText);
shareModal.openModal({
title: '✅ ¡Compartido!',
message: 'Texto copiado al portapapeles. ¡Compártelo en tus redes sociales!'
});
};
const quickStartActions = [
{
id: 'dashboard',
title: 'Ir al Dashboard',
description: 'Explora tu panel de control personalizado',
icon: <ArrowRight className="w-5 h-5" />,
action: () => onNext(),
primary: true
},
{
id: 'inventory',
title: 'Gestionar Inventario',
description: 'Revisa y actualiza tu stock actual',
icon: <Gift className="w-5 h-5" />,
action: () => console.log('Navigate to inventory'),
primary: false
},
{
id: 'predictions',
title: 'Ver Predicciones IA',
description: 'Consulta las predicciones de demanda',
icon: <Rocket className="w-5 h-5" />,
action: () => console.log('Navigate to predictions'),
primary: false
}
];
const achievementBadges = [
{
title: 'Pionero IA',
description: 'Primera configuración con Machine Learning',
icon: '🤖',
earned: data.trainingStatus === 'completed'
},
{
title: 'Organizador',
description: 'Inventario completamente configurado',
icon: '📦',
earned: (data.inventoryItems?.length || 0) >= 5
},
{
title: 'Analista',
description: 'Análisis de datos exitoso',
icon: '📊',
earned: data.analysisStatus === 'completed'
},
{
title: 'Perfeccionista',
description: 'Puntuación de configuración 90+',
icon: '⭐',
earned: (completionStats?.completionScore || 0) >= 90
}
];
return (
<div className="space-y-6">
{/* Confetti Effect */}
{showConfetti && (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-6xl animate-bounce">🎉</div>
</div>
{/* Additional confetti elements could be added here */}
</div>
)}
<div className="text-center space-y-8">
{/* Success Icon */}
<div className="mx-auto w-24 h-24 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<svg className="w-12 h-12 text-[var(--color-success)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Celebration Header */}
<div className="text-center mb-8">
<div className="w-24 h-24 bg-gradient-to-br from-[var(--color-success)] to-[var(--color-primary)] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
<CheckCircle className="w-12 h-12 text-white" />
</div>
<h1 className="text-4xl font-bold text-[var(--text-primary)] mb-4">
¡Felicidades! 🎉
{/* Success Message */}
<div className="space-y-4">
<h1 className="text-3xl font-bold text-[var(--text-primary)]">
¡Bienvenido a Bakery IA!
</h1>
<h2 className="text-xl font-semibold text-[var(--color-success)] mb-4">
{data.bakery?.name} está listo para funcionar
</h2>
<p className="text-[var(--text-secondary)] max-w-3xl mx-auto text-lg leading-relaxed">
Has completado exitosamente la configuración inicial de tu panadería inteligente.
Tu sistema está optimizado con IA y listo para transformar la gestión de tu negocio.
<p className="text-lg text-[var(--text-secondary)] max-w-2xl mx-auto">
Tu panadería <strong>{currentTenant?.name}</strong> está lista para usar nuestro sistema de gestión inteligente.
</p>
</div>
{/* Completion Stats */}
{completionStats && (
<Card className="p-6">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-[var(--color-success)] to-[var(--color-primary)] rounded-full mb-4">
<span className="text-2xl font-bold text-white">{completionStats.completionScore}</span>
<span className="text-sm text-white/80 ml-1">/100</span>
</div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
Puntuación de Configuración
</h3>
<p className="text-sm text-[var(--text-secondary)]">
¡Excelente trabajo! Has superado el promedio de la industria (78/100)
</p>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 text-center">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg mx-auto mb-4 flex items-center justify-center">
<svg className="w-6 h-6 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<h3 className="font-semibold mb-2">Panadería Registrada</h3>
<p className="text-sm text-[var(--text-secondary)]">
Tu información empresarial está configurada y lista
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-info)]">{completionStats.totalProducts}</p>
<p className="text-xs text-[var(--text-secondary)]">Productos</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-primary)]">{completionStats.inventoryItems}</p>
<p className="text-xs text-[var(--text-secondary)]">Inventario</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{completionStats.suppliersConfigured}</p>
<p className="text-xs text-[var(--text-secondary)]">Proveedores</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p>
<p className="text-xs text-[var(--text-secondary)]">Precisión IA</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{completionStats.salesImported ? completionStats.salesImportRecords : '⏳'}</p>
<p className="text-xs text-[var(--text-secondary)]">Ventas {completionStats.salesImported ? 'Importadas' : 'Importando...'}</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>
</div>
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 text-center">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg mx-auto mb-4 flex items-center justify-center">
<svg className="w-6 h-6 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
</Card>
)}
{/* Achievement Badges */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 text-center">
🏆 Logros Desbloqueados
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{achievementBadges.map((badge, index) => (
<div
key={index}
className={`text-center p-4 rounded-lg border-2 transition-all duration-200 ${
badge.earned
? 'border-[var(--color-success)]/50 bg-[var(--color-success)]/10'
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)] opacity-50'
}`}
>
<div className="text-2xl mb-2">{badge.icon}</div>
<h4 className="font-medium text-[var(--text-primary)] mb-1 text-sm">{badge.title}</h4>
<p className="text-xs text-[var(--text-secondary)]">{badge.description}</p>
{badge.earned && (
<div className="mt-2">
<Badge variant="success" className="text-xs">Conseguido</Badge>
</div>
)}
</div>
))}
<h3 className="font-semibold mb-2">Inventario Creado</h3>
<p className="text-sm text-[var(--text-secondary)]">
Tus productos base están configurados con datos iniciales
</p>
</div>
</Card>
{/* Quick Actions */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
🚀 Próximos Pasos
</h3>
<div className="space-y-3">
{quickStartActions.map((action) => (
<button
key={action.id}
onClick={action.action}
className={`w-full text-left p-4 rounded-lg border transition-all duration-200 hover:shadow-md ${
action.primary
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)]'
}`}
>
<div className="flex items-center space-x-4">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
action.primary ? 'bg-[var(--color-primary)]' : 'bg-[var(--bg-tertiary)]'
}`}>
<span className={action.primary ? 'text-white' : 'text-[var(--text-secondary)]'}>
{action.icon}
</span>
</div>
<div className="flex-1">
<h4 className={`font-medium ${
action.primary ? 'text-[var(--color-primary)]' : 'text-[var(--text-primary)]'
}`}>
{action.title}
</h4>
<p className="text-sm text-[var(--text-secondary)]">{action.description}</p>
</div>
<ArrowRight className="w-4 h-4 text-[var(--text-tertiary)]" />
</div>
</button>
))}
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 text-center">
<div className="w-12 h-12 bg-[var(--color-primary)]/10 rounded-lg mx-auto mb-4 flex items-center justify-center">
<svg className="w-6 h-6 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<h3 className="font-semibold mb-2">IA Entrenada</h3>
<p className="text-sm text-[var(--text-secondary)]">
Tu modelo de inteligencia artificial está listo para predecir demanda
</p>
</div>
</Card>
{/* Additional Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4 text-center">
<Download className="w-6 h-6 text-[var(--color-info)] mx-auto mb-2" />
<h4 className="font-medium text-[var(--text-primary)] mb-2">Certificado</h4>
<p className="text-sm text-[var(--text-secondary)] mb-3">
Descarga tu certificado de configuración
</p>
<Button size="sm" variant="outline" onClick={generateCertificate}>
Descargar
</Button>
</Card>
<Card className="p-4 text-center">
<Calendar className="w-6 h-6 text-[var(--color-primary)] mx-auto mb-2" />
<h4 className="font-medium text-[var(--text-primary)] mb-2">Demo Personal</h4>
<p className="text-sm text-[var(--text-secondary)] mb-3">
Agenda una demostración 1-a-1
</p>
<Button size="sm" variant="outline" onClick={scheduleDemo}>
Agendar
</Button>
</Card>
<Card className="p-4 text-center">
<Share2 className="w-6 h-6 text-[var(--color-success)] mx-auto mb-2" />
<h4 className="font-medium text-[var(--text-primary)] mb-2">Compartir Éxito</h4>
<p className="text-sm text-[var(--text-secondary)] mb-3">
Comparte tu logro en redes sociales
</p>
<Button size="sm" variant="outline" onClick={shareSuccess}>
Compartir
</Button>
</Card>
</div>
{/* Summary & Thanks */}
<Card className="p-6 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-success)]/5 border-[var(--color-primary)]/20">
<div className="text-center">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-3">
🙏 ¡Gracias por confiar en nuestra plataforma!
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Tu panadería ahora cuenta con tecnología de vanguardia para:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left mb-6">
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Predicciones de demanda con IA</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Gestión inteligente de inventario</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Optimización de compras</span>
</div>
{/* Next Steps */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-6 max-w-2xl mx-auto text-left">
<h3 className="font-semibold mb-4 text-center">Próximos Pasos</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-[var(--color-primary)] text-white rounded-full flex items-center justify-center text-sm font-medium flex-shrink-0 mt-0.5">
1
</div>
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Alertas automáticas de restock</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Análisis de tendencias de venta</span>
</div>
<div className="flex items-center space-x-2 text-sm">
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
<span>Control multi-tenant seguro</span>
</div>
<div>
<p className="font-medium">Explora el Dashboard</p>
<p className="text-sm text-[var(--text-secondary)]">
Revisa las métricas principales y el estado de tu inventario
</p>
</div>
</div>
<div className="bg-[var(--color-info)]/10 rounded-lg p-4">
<p className="text-sm text-[var(--color-info)]">
💡 <strong>Consejo:</strong> Explora el dashboard para descubrir todas las funcionalidades disponibles.
El sistema aprenderá de tus patrones y mejorará sus recomendaciones con el tiempo.
</p>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-[var(--color-primary)] text-white rounded-full flex items-center justify-center text-sm font-medium flex-shrink-0 mt-0.5">
2
</div>
<div>
<p className="font-medium">Registra Ventas Diarias</p>
<p className="text-sm text-[var(--text-secondary)]">
Mantén tus datos actualizados para mejores predicciones
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-[var(--color-primary)] text-white rounded-full flex items-center justify-center text-sm font-medium flex-shrink-0 mt-0.5">
3
</div>
<div>
<p className="font-medium">Configura Alertas</p>
<p className="text-sm text-[var(--text-secondary)]">
Recibe notificaciones sobre inventario bajo y productos próximos a vencer
</p>
</div>
</div>
</div>
</Card>
</div>
{/* Action Button */}
<div className="pt-4">
<Button
onClick={handleGetStarted}
size="lg"
className="px-8"
>
Comenzar a Usar Bakery IA
</Button>
</div>
{/* Help Text */}
<div className="text-sm text-[var(--text-tertiary)]">
¿Necesitas ayuda? Visita nuestra{' '}
<a
href="/help"
className="text-[var(--color-primary)] hover:underline"
target="_blank"
rel="noopener noreferrer"
>
guía de usuario
</a>{' '}
o contacta a nuestro{' '}
<a
href="mailto:support@bakery-ia.com"
className="text-[var(--color-primary)] hover:underline"
>
equipo de soporte
</a>
</div>
</div>
);
};

View File

@@ -1,422 +1,264 @@
import React, { useState, useEffect, useRef } from 'react';
import { Brain, Activity, Zap, CheckCircle, AlertCircle, TrendingUp, Upload, Database } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { useOnboarding } from '../../../../hooks/business/onboarding';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
import React, { useState, useEffect } from 'react';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training';
// Type definitions for training messages (will be moved to API types later)
interface TrainingProgressMessage {
type: 'training_progress';
progress: number;
interface MLTrainingStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
interface TrainingProgress {
stage: string;
message: string;
}
interface TrainingCompletedMessage {
type: 'training_completed';
metrics: TrainingMetrics;
}
interface TrainingErrorMessage {
type: 'training_error';
error: string;
}
interface TrainingMetrics {
accuracy: number;
mape: number;
mae: number;
rmse: number;
}
interface TrainingLog {
timestamp: string;
message: string;
level: 'info' | 'warning' | 'error' | 'success';
}
interface TrainingJob {
id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
started_at?: string;
completed_at?: string;
error_message?: string;
metrics?: TrainingMetrics;
message: string;
currentStep?: string;
estimatedTimeRemaining?: number;
}
// Using the proper training service from services/api/training.service.ts
export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
data,
onDataChange,
onNext,
export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
onPrevious,
isFirstStep,
isLastStep
onComplete,
isFirstStep
}) => {
const user = useAuthUser();
const [trainingProgress, setTrainingProgress] = useState<TrainingProgress | null>(null);
const [isTraining, setIsTraining] = useState(false);
const [error, setError] = useState<string>('');
const [jobId, setJobId] = useState<string | null>(null);
const currentTenant = useCurrentTenant();
// Use the onboarding hooks
const {
startTraining,
trainingOrchestration: {
status,
progress,
currentStep,
estimatedTimeRemaining,
job,
logs,
metrics
},
data: allStepData,
isLoading,
error,
clearError
} = useOnboarding();
// Local state for UI-only elements
const [hasStarted, setHasStarted] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const createTrainingJob = useCreateTrainingJob();
// Validate that required data is available for training
const validateDataRequirements = (): { isValid: boolean; missingItems: string[] } => {
const missingItems: string[] = [];
console.log('MLTrainingStep - Validating data requirements');
console.log('MLTrainingStep - Current allStepData:', allStepData);
// Check if sales data was processed
const hasProcessingResults = allStepData?.processingResults &&
allStepData.processingResults.is_valid &&
allStepData.processingResults.total_records > 0;
// Check if sales data was imported (required for training)
const hasImportResults = allStepData?.salesImportResult &&
(allStepData.salesImportResult.records_created > 0 ||
allStepData.salesImportResult.success === true ||
allStepData.salesImportResult.imported === true);
if (!hasProcessingResults) {
missingItems.push('Datos de ventas validados');
// WebSocket for real-time training progress
const trainingWebSocket = useTrainingWebSocket(
currentTenant?.id || '',
jobId || '',
undefined, // token will be handled by the service
{
onProgress: (data) => {
setTrainingProgress({
stage: 'training',
progress: data.progress?.percentage || 0,
message: data.message || 'Entrenando modelo...',
currentStep: data.progress?.current_step,
estimatedTimeRemaining: data.progress?.estimated_time_remaining
});
},
onCompleted: (data) => {
setTrainingProgress({
stage: 'completed',
progress: 100,
message: 'Entrenamiento completado exitosamente'
});
setIsTraining(false);
setTimeout(() => {
onComplete({
jobId: jobId,
success: true,
message: 'Modelo entrenado correctamente'
});
}, 2000);
},
onError: (data) => {
setError(data.error || 'Error durante el entrenamiento');
setIsTraining(false);
setTrainingProgress(null);
},
onStarted: (data) => {
setTrainingProgress({
stage: 'starting',
progress: 5,
message: 'Iniciando entrenamiento del modelo...'
});
}
}
// Sales data must be imported for ML training to work
if (!hasImportResults) {
missingItems.push('Datos de ventas importados');
}
// Check if products were approved in review step
const hasApprovedProducts = allStepData?.approvedProducts &&
allStepData.approvedProducts.length > 0 &&
allStepData.reviewCompleted;
if (!hasApprovedProducts) {
missingItems.push('Productos aprobados en revisión');
}
// Check if inventory was configured
const hasInventoryConfig = allStepData?.inventoryConfigured &&
allStepData?.inventoryItems &&
allStepData.inventoryItems.length > 0;
if (!hasInventoryConfig) {
missingItems.push('Inventario configurado');
}
// Check if we have enough data for training
if (dataProcessingData?.processingResults?.total_records &&
dataProcessingData.processingResults.total_records < 10) {
missingItems.push('Suficientes registros de ventas (mínimo 10)');
}
console.log('MLTrainingStep - Validation result:', {
isValid: missingItems.length === 0,
missingItems,
hasProcessingResults,
hasImportResults,
hasApprovedProducts,
hasInventoryConfig
});
return {
isValid: missingItems.length === 0,
missingItems
};
};
);
const handleStartTraining = async () => {
// Validate data requirements
const validation = validateDataRequirements();
if (!validation.isValid) {
console.error('Datos insuficientes para entrenamiento:', validation.missingItems);
if (!currentTenant?.id) {
setError('No se encontró información del tenant');
return;
}
setHasStarted(true);
// Use the onboarding hook for training
const success = await startTraining({
// You can pass options here if needed
startDate: allStepData?.processingResults?.summary?.date_range?.split(' - ')[0],
endDate: allStepData?.processingResults?.summary?.date_range?.split(' - ')[1],
setIsTraining(true);
setError('');
setTrainingProgress({
stage: 'preparing',
progress: 0,
message: 'Preparando datos para entrenamiento...'
});
if (!success) {
console.error('Error starting training');
setHasStarted(false);
}
try {
const response = await createTrainingJob.mutateAsync({
tenantId: currentTenant.id,
request: {
// Use the exact backend schema - all fields are optional
// This will train on all available data
}
});
setJobId(response.job_id);
setTrainingProgress({
stage: 'queued',
progress: 10,
message: 'Trabajo de entrenamiento en cola...'
});
} catch (err) {
setError('Error al iniciar el entrenamiento del modelo');
setIsTraining(false);
setTrainingProgress(null);
}
};
// Cleanup WebSocket on unmount
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.disconnect();
wsRef.current = null;
}
};
}, []);
useEffect(() => {
// Auto-start training if all requirements are met and not already started
const validation = validateDataRequirements();
console.log('MLTrainingStep - useEffect validation:', validation);
const formatTime = (seconds?: number) => {
if (!seconds) return '';
if (validation.isValid && status === 'idle' && data.autoStartTraining) {
console.log('MLTrainingStep - Auto-starting training...');
// Auto-start after a brief delay to allow user to see the step
const timer = setTimeout(() => {
handleStartTraining();
}, 1000);
return () => clearTimeout(timer);
}
}, [allStepData, data.autoStartTraining, status]);
const getStatusIcon = () => {
switch (status) {
case 'idle': return <Brain className="w-8 h-8 text-[var(--color-primary)]" />;
case 'validating': return <Database className="w-8 h-8 text-[var(--color-info)] animate-pulse" />;
case 'training': return <Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />;
case 'completed': return <CheckCircle className="w-8 h-8 text-[var(--color-success)]" />;
case 'failed': return <AlertCircle className="w-8 h-8 text-[var(--color-error)]" />;
default: return <Brain className="w-8 h-8 text-[var(--color-primary)]" />;
if (seconds < 60) {
return `${Math.round(seconds)}s`;
} else if (seconds < 3600) {
return `${Math.round(seconds / 60)}m`;
} else {
return `${Math.round(seconds / 3600)}h ${Math.round((seconds % 3600) / 60)}m`;
}
};
const getStatusColor = () => {
switch (status) {
case 'completed': return 'text-[var(--color-success)]';
case 'failed': return 'text-[var(--color-error)]';
case 'training':
case 'validating': return 'text-[var(--color-info)]';
default: return 'text-[var(--text-primary)]';
}
};
const getStatusMessage = () => {
switch (status) {
case 'idle': return 'Listo para entrenar tu asistente IA';
case 'validating': return 'Validando datos para entrenamiento...';
case 'training': return 'Entrenando modelo de predicción...';
case 'completed': return '¡Tu asistente IA está listo!';
case 'failed': return 'Error en el entrenamiento';
default: return 'Estado desconocido';
}
};
// Check data requirements for display
const validation = validateDataRequirements();
if (!validation.isValid) {
return (
<div className="space-y-8">
<div className="text-center py-16">
<Upload className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-gray-600 mb-4">
Datos insuficientes para entrenamiento
</h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
Para entrenar tu modelo de IA, necesitamos que completes los siguientes elementos:
</p>
<Card className="p-6 max-w-md mx-auto mb-6">
<h4 className="font-semibold mb-4 text-[var(--text-primary)]">Elementos requeridos:</h4>
<ul className="space-y-2 text-left">
{validation.missingItems.map((item, index) => (
<li key={index} className="flex items-center space-x-2">
<AlertCircle className="w-4 h-4 text-red-500" />
<span className="text-sm text-[var(--text-secondary)]">{item}</span>
</li>
))}
</ul>
</Card>
<p className="text-sm text-[var(--text-secondary)] mb-4">
Una vez completados estos elementos, el entrenamiento se iniciará automáticamente.
</p>
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Volver al paso anterior
</Button>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="space-y-6">
<div className="text-center">
<div className="w-20 h-20 bg-gradient-to-br from-[var(--color-primary)] to-[var(--color-secondary)] rounded-full flex items-center justify-center mx-auto mb-6">
{getStatusIcon()}
</div>
<h2 className={`text-3xl font-bold mb-4 ${getStatusColor()}`}>
Entrenamiento de IA
</h2>
<p className={`text-lg mb-2 ${getStatusColor()}`}>
{getStatusMessage()}
</p>
<p className="text-[var(--text-secondary)]">
Creando tu asistente inteligente personalizado con tus datos de ventas e inventario
<p className="text-[var(--text-secondary)] mb-6">
Ahora entrenaremos tu modelo de inteligencia artificial utilizando los datos de ventas
e inventario que has proporcionado. Este proceso puede tomar varios minutos.
</p>
</div>
{/* Progress Bar */}
<Card className="p-6">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-[var(--text-primary)]">Progreso del entrenamiento</span>
<span className="text-sm text-[var(--text-secondary)]">{progress.toFixed(1)}%</span>
</div>
{currentStep && (
<div className="mb-2">
<span className="text-xs text-[var(--text-secondary)]">Paso actual: </span>
<span className="text-xs font-medium text-[var(--text-primary)]">{currentStep}</span>
</div>
)}
{estimatedTimeRemaining > 0 && (
<div className="mb-4">
<span className="text-xs text-[var(--text-secondary)]">Tiempo estimado restante: </span>
<span className="text-xs font-medium text-[var(--color-primary)]">
{Math.round(estimatedTimeRemaining / 60)} minutos
</span>
</div>
)}
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3">
<div
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-secondary)] h-3 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</Card>
{/* Training Logs */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Activity className="w-5 h-5 mr-2" />
Registro de entrenamiento
</h3>
<div className="space-y-2 max-h-64 overflow-y-auto">
{trainingLogs.length === 0 ? (
<p className="text-[var(--text-secondary)] italic">Esperando inicio de entrenamiento...</p>
) : (
trainingLogs.map((log, index) => (
<div key={index} className="flex items-start space-x-3 p-2 rounded">
<div className={`w-2 h-2 rounded-full mt-2 ${
log.level === 'success' ? 'bg-green-500' :
log.level === 'error' ? 'bg-red-500' :
log.level === 'warning' ? 'bg-yellow-500' :
'bg-blue-500'
}`} />
<div className="flex-1">
<p className="text-sm text-[var(--text-primary)]">{log.message}</p>
<p className="text-xs text-[var(--text-secondary)]">
{new Date(log.timestamp).toLocaleTimeString()}
</p>
</div>
{/* Training Status Card */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-6">
<div className="text-center">
{!isTraining && !trainingProgress && (
<div className="space-y-4">
<div className="mx-auto w-16 h-16 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
))
<div>
<h3 className="text-lg font-semibold mb-2">Listo para Entrenar</h3>
<p className="text-[var(--text-secondary)] text-sm">
Tu modelo está listo para ser entrenado con los datos proporcionados.
</p>
</div>
</div>
)}
{trainingProgress && (
<div className="space-y-4">
<div className="mx-auto w-16 h-16 relative">
{trainingProgress.stage === 'completed' ? (
<div className="w-16 h-16 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-[var(--color-success)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : (
<div className="w-16 h-16 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
</div>
)}
{trainingProgress.progress > 0 && trainingProgress.stage !== 'completed' && (
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2">
<span className="text-xs font-medium text-[var(--text-tertiary)]">
{trainingProgress.progress}%
</span>
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold mb-2">
{trainingProgress.stage === 'completed'
? '¡Entrenamiento Completo!'
: 'Entrenando Modelo IA'
}
</h3>
<p className="text-[var(--text-secondary)] text-sm mb-4">
{trainingProgress.message}
</p>
{trainingProgress.stage !== 'completed' && (
<div className="space-y-2">
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${trainingProgress.progress}%` }}
/>
</div>
<div className="flex justify-between text-xs text-[var(--text-tertiary)]">
<span>{trainingProgress.currentStep || 'Procesando...'}</span>
{trainingProgress.estimatedTimeRemaining && (
<span>Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)}</span>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
</Card>
</div>
{/* Training Metrics */}
{metrics && status === 'completed' && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<TrendingUp className="w-5 h-5 mr-2" />
Métricas del modelo
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">
{(metrics.accuracy * 100).toFixed(1)}%
</p>
<p className="text-xs text-[var(--text-secondary)]">Precisión</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-info)]">
{metrics.mape.toFixed(1)}%
</p>
<p className="text-xs text-[var(--text-secondary)]">MAPE</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-primary)]">
{metrics.mae.toFixed(2)}
</p>
<p className="text-xs text-[var(--text-secondary)]">MAE</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-secondary)]">
{metrics.rmse.toFixed(2)}
</p>
<p className="text-xs text-[var(--text-secondary)]">RMSE</p>
</div>
</div>
</Card>
{/* Training Info */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<h4 className="font-medium mb-2">¿Qué sucede durante el entrenamiento?</h4>
<ul className="text-sm text-[var(--text-secondary)] space-y-1">
<li> Análisis de patrones de ventas históricos</li>
<li> Creación de modelos predictivos de demanda</li>
<li> Optimización de algoritmos de inventario</li>
<li> Validación y ajuste de precisión</li>
</ul>
</div>
{error && (
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
<p className="text-[var(--color-error)]">{error}</p>
</div>
)}
{/* Manual Start Button (if not auto-started) */}
{status === 'idle' && (
<Card className="p-6 text-center">
<Button
onClick={handleStartTraining}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
size="lg"
>
<Zap className="w-5 h-5 mr-2" />
Iniciar entrenamiento
</Button>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
{/* Actions */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
disabled={isFirstStep || isTraining}
>
Anterior
</Button>
<Button
onClick={onNext}
disabled={trainingStatus !== 'completed'}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
{!isTraining && !trainingProgress && (
<Button
onClick={handleStartTraining}
size="lg"
disabled={!currentTenant?.id}
>
Iniciar Entrenamiento
</Button>
)}
{trainingProgress?.stage === 'completed' && (
<Button
onClick={() => onComplete()}
size="lg"
variant="success"
>
Continuar
</Button>
)}
</div>
</div>
);

View File

@@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import { useRegisterBakery } from '../../../../api/hooks/tenant';
import { BakeryRegistration } from '../../../../api/types/tenant';
interface RegisterTenantStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
export const RegisterTenantStep: React.FC<RegisterTenantStepProps> = ({
onComplete,
isFirstStep
}) => {
const [formData, setFormData] = useState<BakeryRegistration>({
name: '',
address: '',
postal_code: '',
phone: '',
city: 'Madrid',
business_type: 'bakery',
business_model: 'individual_bakery'
});
const [errors, setErrors] = useState<Record<string, string>>({});
const registerBakery = useRegisterBakery();
const handleInputChange = (field: keyof BakeryRegistration, value: string) => {
setFormData(prev => ({
...prev,
[field]: value
}));
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: ''
}));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// Required fields according to backend BakeryRegistration schema
if (!formData.name.trim()) {
newErrors.name = 'El nombre de la panadería es obligatorio';
} else if (formData.name.length < 2 || formData.name.length > 200) {
newErrors.name = 'El nombre debe tener entre 2 y 200 caracteres';
}
if (!formData.address.trim()) {
newErrors.address = 'La dirección es obligatoria';
} else if (formData.address.length < 10 || formData.address.length > 500) {
newErrors.address = 'La dirección debe tener entre 10 y 500 caracteres';
}
if (!formData.postal_code.trim()) {
newErrors.postal_code = 'El código postal es obligatorio';
} else if (!/^\d{5}$/.test(formData.postal_code)) {
newErrors.postal_code = 'El código postal debe tener exactamente 5 dígitos';
}
if (!formData.phone.trim()) {
newErrors.phone = 'El número de teléfono es obligatorio';
} else if (formData.phone.length < 9 || formData.phone.length > 20) {
newErrors.phone = 'El teléfono debe tener entre 9 y 20 caracteres';
} else {
// Basic Spanish phone validation
const phone = formData.phone.replace(/[\s\-\(\)]/g, '');
const patterns = [
/^(\+34|0034|34)?[6789]\d{8}$/, // Mobile
/^(\+34|0034|34)?9\d{8}$/ // Landline
];
if (!patterns.some(pattern => pattern.test(phone))) {
newErrors.phone = 'Introduce un número de teléfono español válido';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
return;
}
try {
const tenant = await registerBakery.mutateAsync(formData);
onComplete({ tenant });
} catch (error) {
console.error('Error registering bakery:', error);
setErrors({ submit: 'Error al registrar la panadería. Por favor, inténtalo de nuevo.' });
}
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Input
label="Nombre de la Panadería"
placeholder="Ingresa el nombre de tu panadería"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
isRequired
/>
<Input
label="Teléfono"
type="tel"
placeholder="+34 123 456 789"
value={formData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
error={errors.phone}
isRequired
/>
<div className="md:col-span-2">
<Input
label="Dirección"
placeholder="Calle Principal 123, Ciudad, Provincia"
value={formData.address}
onChange={(e) => handleInputChange('address', e.target.value)}
error={errors.address}
isRequired
/>
</div>
<Input
label="Código Postal"
placeholder="28001"
value={formData.postal_code}
onChange={(e) => handleInputChange('postal_code', e.target.value)}
error={errors.postal_code}
isRequired
maxLength={5}
/>
<Input
label="Ciudad (Opcional)"
placeholder="Madrid"
value={formData.city}
onChange={(e) => handleInputChange('city', e.target.value)}
error={errors.city}
/>
</div>
{errors.submit && (
<div className="text-[var(--color-error)] text-sm bg-[var(--color-error)]/10 p-3 rounded-lg">
{errors.submit}
</div>
)}
<div className="flex justify-end">
<Button
onClick={handleSubmit}
isLoading={registerBakery.isPending}
loadingText="Registrando..."
size="lg"
>
Crear Panadería y Continuar
</Button>
</div>
</div>
);
};

View File

@@ -1,816 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { useModal } from '../../../../hooks/ui/useModal';
import { useToast } from '../../../../hooks/ui/useToast';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores';
// TODO: Import procurement service from new API when available
// Frontend supplier interface that matches the form needs
interface SupplierFormData {
id?: string;
name: string;
contact_name: string;
phone: string;
email: string;
address: string;
payment_terms: string;
delivery_terms: string;
tax_id?: string;
is_active: boolean;
}
const commonCategories = [
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes'
];
const paymentTermsOptions = [
'Inmediato',
'15 días',
'30 días',
'45 días',
'60 días',
'90 días'
];
export const SuppliersStep: React.FC<OnboardingStepProps> = ({
data,
onDataChange,
onNext,
onPrevious,
isFirstStep,
isLastStep
}) => {
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const createAlert = (alert: any) => {
console.log('Alert:', alert);
};
const { showToast } = useToast();
// Use modals for confirmations and editing
const deleteModal = useModal();
const editModal = useModal();
const [suppliers, setSuppliers] = useState<any[]>([]);
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
// Load suppliers from backend on component mount
useEffect(() => {
const loadSuppliers = async () => {
// Check if we already have suppliers loaded
if (data.suppliers && Array.isArray(data.suppliers) && data.suppliers.length > 0) {
setSuppliers(data.suppliers);
return;
}
if (!currentTenant?.id) {
return;
}
setLoading(true);
try {
const response = await procurementService.getSuppliers({ size: 100 });
if (response.success && response.data) {
setSuppliers(response.data.items);
}
} catch (error) {
console.error('Failed to load suppliers:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'medium',
title: 'Error al cargar proveedores',
message: 'No se pudieron cargar los proveedores existentes.',
source: 'onboarding'
});
} finally {
setLoading(false);
}
};
loadSuppliers();
}, [currentTenant?.id]);
// Update parent data when suppliers change
useEffect(() => {
onDataChange({
...data,
suppliers: suppliers,
suppliersConfigured: true // This step is optional
});
}, [suppliers]);
const handleAddSupplier = () => {
const newSupplier: SupplierFormData = {
name: '',
contact_name: '',
phone: '',
email: '',
address: '',
payment_terms: '30 días',
delivery_terms: 'Recoger en tienda',
tax_id: '',
is_active: true
};
setEditingSupplier(newSupplier);
setIsAddingNew(true);
};
const handleSaveSupplier = async (supplierData: SupplierFormData) => {
if (isAddingNew) {
setCreating(true);
try {
const response = await procurementService.createSupplier({
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => [...prev, response.data]);
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor creado',
message: `El proveedor ${response.data.name} se ha creado exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to create supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al crear proveedor',
message: 'No se pudo crear el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setCreating(false);
}
} else {
// Update existing supplier
if (!supplierData.id) return;
setUpdating(true);
try {
const response = await procurementService.updateSupplier(supplierData.id, {
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s => s.id === response.data.id ? response.data : s));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor actualizado',
message: `El proveedor ${response.data.name} se ha actualizado exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to update supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al actualizar proveedor',
message: 'No se pudo actualizar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setUpdating(false);
}
}
setEditingSupplier(null);
setIsAddingNew(false);
};
const handleDeleteSupplier = async (id: string) => {
deleteModal.openModal({
title: 'Confirmar eliminación',
message: '¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.',
onConfirm: () => performDelete(id),
onCancel: () => deleteModal.closeModal()
});
return;
};
const performDelete = async (id: string) => {
deleteModal.closeModal();
setDeleting(id);
try {
const response = await procurementService.deleteSupplier(id);
if (response.success) {
setSuppliers(prev => prev.filter(s => s.id !== id));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor eliminado',
message: 'El proveedor se ha eliminado exitosamente.',
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to delete supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al eliminar proveedor',
message: 'No se pudo eliminar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setDeleting(null);
}
};
const toggleSupplierStatus = async (id: string, currentStatus: boolean) => {
try {
const response = await procurementService.updateSupplier(id, {
is_active: !currentStatus
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s =>
s.id === id ? response.data : s
));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Estado actualizado',
message: `El proveedor se ha ${!currentStatus ? 'activado' : 'desactivado'} exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to toggle supplier status:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al cambiar estado',
message: 'No se pudo cambiar el estado del proveedor.',
source: 'onboarding'
});
}
};
const getFilteredSuppliers = () => {
if (filterStatus === 'all') {
return suppliers;
}
return suppliers.filter(s =>
filterStatus === 'active' ? s.is_active : !s.is_active
);
};
const stats = {
total: suppliers.length,
active: suppliers.filter(s => s.is_active).length,
inactive: suppliers.filter(s => !s.is_active).length,
totalOrders: suppliers.reduce((sum, s) => sum + s.performance_metrics.total_orders, 0)
};
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Optional Step Notice */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<p className="text-sm text-[var(--color-info)]">
💡 <strong>Paso Opcional:</strong> Puedes saltar este paso y configurar proveedores más tarde desde el módulo de compras
</p>
</Card>
{/* Quick Actions */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<p className="text-sm text-[var(--text-secondary)]">
{stats.total} proveedores configurados ({stats.active} activos)
</p>
</div>
</div>
</Card>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
<p className="text-xs text-[var(--text-secondary)]">Total</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.active}</p>
<p className="text-xs text-[var(--text-secondary)]">Activos</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--text-tertiary)]">{stats.inactive}</p>
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalOrders}</p>
<p className="text-xs text-[var(--text-secondary)]">Órdenes</p>
</Card>
</div>
{/* Filters */}
<Card className="p-4">
<div className="flex items-center space-x-4">
<label className="text-sm font-medium text-[var(--text-secondary)]">Estado:</label>
<div className="flex space-x-2">
{[
{ value: 'all', label: 'Todos' },
{ value: 'active', label: 'Activos' },
{ value: 'inactive', label: 'Inactivos' }
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterStatus(filter.value as any)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterStatus === filter.value
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
</Card>
{/* Suppliers List */}
<div className="space-y-4">
{getFilteredSuppliers().map((supplier) => (
<Card key={supplier.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
supplier.is_active
? 'bg-[var(--color-success)]/10'
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
}`}>
<Truck className={`w-4 h-4 ${
supplier.is_active
? 'text-[var(--color-success)]'
: 'text-[var(--text-tertiary)]'
}`} />
</div>
{/* Supplier Info */}
<div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4>
<div className="flex items-center gap-2 mb-3">
<Badge variant={supplier.is_active ? 'green' : 'gray'}>
{supplier.is_active ? 'Activo' : 'Inactivo'}
</Badge>
{supplier.rating && (
<Badge variant="blue"> {supplier.rating.toFixed(1)}</Badge>
)}
{supplier.performance_metrics.total_orders > 0 && (
<Badge variant="purple">{supplier.performance_metrics.total_orders} órdenes</Badge>
)}
</div>
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
{supplier.contact_name && (
<div>
<span className="text-[var(--text-tertiary)]">Contacto: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_name}</span>
</div>
)}
{supplier.delivery_terms && (
<div>
<span className="text-[var(--text-tertiary)]">Entrega: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.delivery_terms}</span>
</div>
)}
{supplier.payment_terms && (
<div>
<span className="text-[var(--text-tertiary)]">Pago: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
{supplier.phone && (
<div className="flex items-center gap-1">
<Phone className="w-3 h-3" />
<span>{supplier.phone}</span>
</div>
)}
{supplier.email && (
<div className="flex items-center gap-1">
<Mail className="w-3 h-3" />
<span>{supplier.email}</span>
</div>
)}
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
<span>{supplier.address}</span>
</div>
</div>
{supplier.performance_metrics.on_time_delivery_rate > 0 && (
<div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm">
<span className="text-[var(--text-tertiary)]">Rendimiento: </span>
<span className="text-[var(--text-primary)]">
{supplier.performance_metrics.on_time_delivery_rate}% entregas a tiempo
</span>
{supplier.performance_metrics.quality_score > 0 && (
<span className="text-[var(--text-primary)]">
, {supplier.performance_metrics.quality_score}/5 calidad
</span>
)}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4 mt-1">
<Button
size="sm"
variant="outline"
onClick={() => {
// Convert backend supplier to form data
const formData: SupplierFormData = {
id: supplier.id,
name: supplier.name,
contact_name: supplier.contact_name || '',
phone: supplier.phone || '',
email: supplier.email || '',
address: supplier.address,
payment_terms: supplier.payment_terms || '30 días',
delivery_terms: supplier.delivery_terms || 'Recoger en tienda',
tax_id: supplier.tax_id || '',
is_active: supplier.is_active
};
setEditingSupplier(formData);
setIsAddingNew(false);
}}
disabled={updating}
>
<Edit className="w-4 h-4 mr-1" />
Editar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => toggleSupplierStatus(supplier.id, supplier.is_active)}
className={supplier.is_active
? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10'
: 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10'
}
disabled={updating}
>
{supplier.is_active ? 'Pausar' : 'Activar'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteSupplier(supplier.id)}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
disabled={deleting === supplier.id}
>
{deleting === supplier.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</div>
</Card>
))}
{getFilteredSuppliers().length === 0 && !loading && (
<Card className="p-8 text-center bg-[var(--bg-primary)] border-2 border-dashed border-[var(--border-secondary)]">
<Truck className="w-16 h-16 text-[var(--color-primary)] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
{filterStatus === 'all'
? 'Comienza agregando tu primer proveedor'
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
}
</h3>
<p className="text-[var(--text-secondary)] mb-6 max-w-md mx-auto">
{filterStatus === 'all'
? 'Los proveedores te ayudarán a gestionar tus compras y mantener un control de calidad en tu panadería.'
: 'Ajusta los filtros para ver otros proveedores o agrega uno nuevo.'
}
</p>
<Button
onClick={handleAddSupplier}
disabled={creating}
size="lg"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white px-8 py-3 text-base font-medium shadow-lg"
>
{creating ? (
<Loader className="w-5 h-5 mr-2 animate-spin" />
) : (
<Plus className="w-5 h-5 mr-2" />
)}
{filterStatus === 'all' ? 'Agregar Primer Proveedor' : 'Agregar Proveedor'}
</Button>
</Card>
)}
{/* Floating Action Button for when suppliers exist */}
{suppliers.length > 0 && !editingSupplier && (
<div className="fixed bottom-8 right-8 z-40">
<Button
onClick={handleAddSupplier}
disabled={creating}
className="w-14 h-14 rounded-full bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white shadow-2xl hover:shadow-3xl transition-all duration-200 hover:scale-105"
title="Agregar nuevo proveedor"
>
{creating ? (
<Loader className="w-6 h-6 animate-spin" />
) : (
<Plus className="w-6 h-6" />
)}
</Button>
</div>
)}
</div>
{/* Edit Modal */}
{editingSupplier && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
{isAddingNew ? 'Agregar Proveedor' : 'Editar Proveedor'}
</h3>
<SupplierForm
supplier={editingSupplier}
onSave={handleSaveSupplier}
onCancel={() => {
setEditingSupplier(null);
setIsAddingNew(false);
}}
isCreating={creating}
isUpdating={updating}
/>
</Card>
</div>
)}
{/* Information - Only show when there are suppliers */}
{suppliers.length > 0 && (
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
🚚 Gestión de Proveedores:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Editar información</strong> - Actualiza datos de contacto y términos comerciales</li>
<li> <strong>Gestionar estado</strong> - Activa o pausa proveedores según necesidades</li>
<li> <strong>Revisar rendimiento</strong> - Evalúa entregas a tiempo y calidad de productos</li>
<li> <strong>Filtrar vista</strong> - Usa los filtros para encontrar proveedores específicos</li>
</ul>
</Card>
)}
</div>
);
};
// Component for editing suppliers
interface SupplierFormProps {
supplier: SupplierFormData;
onSave: (supplier: SupplierFormData) => void;
onCancel: () => void;
isCreating: boolean;
isUpdating: boolean;
}
const SupplierForm: React.FC<SupplierFormProps> = ({
supplier,
onSave,
onCancel,
isCreating,
isUpdating
}) => {
const [formData, setFormData] = useState(supplier);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
showToast({
title: 'Error de validación',
message: 'El nombre de la empresa es requerido',
type: 'error'
});
return;
}
if (!formData.address.trim()) {
showToast({
title: 'Error de validación',
message: 'La dirección es requerida',
type: 'error'
});
return;
}
onSave(formData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre de la Empresa *
</label>
<Input
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Molinos del Sur"
disabled={isCreating || isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Persona de Contacto
</label>
<Input
value={formData.contact_name}
onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))}
placeholder="Juan Pérez"
disabled={isCreating || isUpdating}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Teléfono
</label>
<Input
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="+1 555-0123"
disabled={isCreating || isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Email
</label>
<Input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder="ventas@proveedor.com"
disabled={isCreating || isUpdating}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Dirección *
</label>
<Input
value={formData.address}
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
placeholder="Av. Industrial 123, Zona Sur"
disabled={isCreating || isUpdating}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Términos de Pago
</label>
<select
value={formData.payment_terms}
onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))}
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
disabled={isCreating || isUpdating}
>
{paymentTermsOptions.map(term => (
<option key={term} value={term}>{term}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Términos de Entrega
</label>
<Input
value={formData.delivery_terms}
onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))}
placeholder="Recoger en tienda"
disabled={isCreating || isUpdating}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
RUT/NIT (Opcional)
</label>
<Input
value={formData.tax_id || ''}
onChange={(e) => setFormData(prev => ({ ...prev, tax_id: e.target.value }))}
placeholder="12345678-9"
disabled={isCreating || isUpdating}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: e.target.checked }))}
disabled={isCreating || isUpdating}
className="rounded"
/>
<label htmlFor="is_active" className="text-sm text-[var(--text-primary)]">
Proveedor activo
</label>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating || isUpdating}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isCreating || isUpdating}
>
{isCreating || isUpdating ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
{isCreating ? 'Creando...' : 'Actualizando...'}
</>
) : (
'Guardar Proveedor'
)}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,626 @@
import React, { useState, useRef } from 'react';
import { Button } from '../../../ui/Button';
import { Input } from '../../../ui/Input';
import { useValidateFileOnly } from '../../../../api/hooks/dataImport';
import { ImportValidationResponse } from '../../../../api/types/dataImport';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateIngredient } from '../../../../api/hooks/inventory';
import { useImportFileOnly } from '../../../../api/hooks/dataImport';
import { useClassifyProductsBatch } from '../../../../api/hooks/classification';
interface UploadSalesDataStepProps {
onNext: () => void;
onPrevious: () => void;
onComplete: (data?: any) => void;
isFirstStep: boolean;
isLastStep: boolean;
}
interface ProgressState {
stage: string;
progress: number;
message: string;
}
interface InventoryItem {
suggestion_id: string;
suggested_name: string;
category: string;
unit_of_measure: string;
selected: boolean;
stock_quantity: number;
expiration_days: number;
cost_per_unit: number;
confidence_score: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
notes?: string;
}
export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
onPrevious,
onComplete,
isFirstStep
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isValidating, setIsValidating] = useState(false);
const [validationResult, setValidationResult] = useState<ImportValidationResponse | null>(null);
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
const [showInventoryStep, setShowInventoryStep] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string>('');
const [progressState, setProgressState] = useState<ProgressState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentTenant = useCurrentTenant();
const { validateFile } = useValidateFileOnly();
const createIngredient = useCreateIngredient();
const { importFile } = useImportFileOnly();
const classifyProducts = useClassifyProductsBatch();
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setError('');
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setError('');
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
};
const handleValidateFile = async () => {
if (!selectedFile || !currentTenant?.id) return;
setIsValidating(true);
setError('');
setProgressState({ stage: 'preparing', progress: 0, message: 'Preparando validación del archivo...' });
try {
const result = await validateFile(
currentTenant.id,
selectedFile,
{
onProgress: (stage: string, progress: number, message: string) => {
setProgressState({ stage, progress, message });
}
}
);
if (result.success && result.validationResult) {
setValidationResult(result.validationResult);
setProgressState(null);
} else {
setError(result.error || 'Error al validar el archivo');
setProgressState(null);
}
} catch (error) {
setError('Error validando archivo: ' + (error instanceof Error ? error.message : 'Error desconocido'));
setProgressState(null);
}
setIsValidating(false);
};
const handleContinue = () => {
if (validationResult) {
// Generate inventory suggestions based on validation
generateInventorySuggestions();
}
};
const generateInventorySuggestions = async () => {
if (!currentTenant?.id || !validationResult) {
setError('No hay datos de validación disponibles para generar sugerencias');
return;
}
setProgressState({ stage: 'analyzing', progress: 25, message: 'Analizando productos de ventas...' });
try {
// Extract product data from validation result
const products = validationResult.product_summary?.map((product: any) => ({
product_name: product.name,
sales_volume: product.total_quantity,
sales_data: {
total_quantity: product.total_quantity,
average_daily_sales: product.average_daily_sales,
frequency: product.frequency
}
})) || [];
if (products.length === 0) {
setError('No se encontraron productos en los datos de ventas');
setProgressState(null);
return;
}
setProgressState({ stage: 'classifying', progress: 50, message: 'Clasificando productos con IA...' });
// Call the classification API
const suggestions = await classifyProducts.mutateAsync({
tenantId: currentTenant.id,
batchData: { products }
});
setProgressState({ stage: 'preparing', progress: 75, message: 'Preparando sugerencias de inventario...' });
// Convert API response to InventoryItem format
const items: InventoryItem[] = suggestions.map(suggestion => {
// Calculate default stock quantity based on sales data
const defaultStock = Math.max(
Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7), // 1 week supply
1
);
// Estimate cost per unit based on category
const estimatedCost = suggestion.category === 'Dairy' ? 5.0 :
suggestion.category === 'Baking Ingredients' ? 2.0 :
3.0;
return {
suggestion_id: suggestion.suggestion_id,
suggested_name: suggestion.suggested_name,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure,
selected: suggestion.confidence_score > 0.7, // Auto-select high confidence items
stock_quantity: defaultStock,
expiration_days: suggestion.estimated_shelf_life_days || 30,
cost_per_unit: estimatedCost,
confidence_score: suggestion.confidence_score,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
notes: suggestion.notes
};
});
setInventoryItems(items);
setShowInventoryStep(true);
setProgressState(null);
} catch (err) {
console.error('Error generating inventory suggestions:', err);
setError('Error al generar sugerencias de inventario. Por favor, inténtalo de nuevo.');
setProgressState(null);
}
};
const handleToggleSelection = (id: string) => {
setInventoryItems(items =>
items.map(item =>
item.suggestion_id === id ? { ...item, selected: !item.selected } : item
)
);
};
const handleUpdateItem = (id: string, field: keyof InventoryItem, value: number) => {
setInventoryItems(items =>
items.map(item =>
item.suggestion_id === id ? { ...item, [field]: value } : item
)
);
};
const handleSelectAll = () => {
const allSelected = inventoryItems.every(item => item.selected);
setInventoryItems(items =>
items.map(item => ({ ...item, selected: !allSelected }))
);
};
const handleCreateInventory = async () => {
const selectedItems = inventoryItems.filter(item => item.selected);
if (selectedItems.length === 0) {
setError('Por favor selecciona al menos un artículo de inventario para crear');
return;
}
if (!currentTenant?.id) {
setError('No se encontró información del tenant');
return;
}
setIsCreating(true);
setError('');
try {
const createdIngredients = [];
for (const item of selectedItems) {
const ingredientData = {
name: item.suggested_name,
category: item.category,
unit_of_measure: item.unit_of_measure,
minimum_stock_level: Math.ceil(item.stock_quantity * 0.2),
maximum_stock_level: item.stock_quantity * 2,
reorder_point: Math.ceil(item.stock_quantity * 0.3),
shelf_life_days: item.expiration_days,
requires_refrigeration: item.requires_refrigeration,
requires_freezing: item.requires_freezing,
is_seasonal: item.is_seasonal,
cost_per_unit: item.cost_per_unit,
notes: item.notes || `Creado durante onboarding - Confianza: ${Math.round(item.confidence_score * 100)}%`
};
const created = await createIngredient.mutateAsync({
tenantId: currentTenant.id,
ingredientData
});
createdIngredients.push({
...created,
initialStock: item.stock_quantity
});
}
// After inventory creation, import the sales data
console.log('Importing sales data after inventory creation...');
let salesImportResult = null;
try {
if (selectedFile) {
const result = await importFile(
currentTenant.id,
selectedFile,
{
onProgress: (stage, progress, message) => {
console.log(`Import progress: ${stage} - ${progress}% - ${message}`);
setProgressState({
stage: 'importing',
progress,
message: `Importando datos de ventas: ${message}`
});
}
}
);
salesImportResult = result;
if (result.success) {
console.log('Sales data imported successfully');
} else {
console.warn('Sales import completed with issues:', result.error);
}
}
} catch (importError) {
console.error('Error importing sales data:', importError);
// Don't fail the entire process if import fails - the inventory has been created successfully
}
setProgressState(null);
onComplete({
createdIngredients,
totalItems: selectedItems.length,
validationResult,
file: selectedFile,
salesImportResult
});
} catch (err) {
console.error('Error creating inventory items:', err);
setError('Error al crear artículos de inventario. Por favor, inténtalo de nuevo.');
setIsCreating(false);
setProgressState(null);
}
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const selectedCount = inventoryItems.filter(item => item.selected).length;
const allSelected = inventoryItems.length > 0 && inventoryItems.every(item => item.selected);
if (showInventoryStep) {
return (
<div className="space-y-6">
<div className="text-center">
<p className="text-[var(--text-secondary)] mb-6">
Basado en tus datos de ventas, hemos generado estas sugerencias de inventario.
Revisa y selecciona los artículos que te gustaría agregar a tu inventario.
</p>
</div>
{/* Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<p className="font-medium">
{selectedCount} de {inventoryItems.length} artículos seleccionados
</p>
<p className="text-sm text-[var(--text-secondary)]">
Los artículos con alta confianza están preseleccionados
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
>
{allSelected ? 'Deseleccionar Todos' : 'Seleccionar Todos'}
</Button>
</div>
</div>
{/* Inventory Items */}
<div className="space-y-4 max-h-96 overflow-y-auto">
{inventoryItems.map((item) => (
<div
key={item.id}
className={`border rounded-lg p-4 transition-colors ${
item.selected
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)]'
}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 pt-1">
<input
type="checkbox"
checked={item.selected}
onChange={() => handleToggleSelection(item.id)}
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
/>
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="font-medium text-[var(--text-primary)]">
{item.name}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{item.category} Unidad: {item.unit_of_measure}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs bg-[var(--bg-tertiary)] px-2 py-1 rounded">
Confianza: {Math.round(item.confidence_score * 100)}%
</span>
{item.requires_refrigeration && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
Requiere refrigeración
</span>
)}
</div>
</div>
{item.selected && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-3 border-t border-[var(--border-secondary)]">
<Input
label="Stock Inicial"
type="number"
min="0"
value={item.stock_quantity.toString()}
onChange={(e) => handleUpdateItem(
item.id,
'stock_quantity',
Number(e.target.value)
)}
size="sm"
/>
<Input
label="Costo por Unidad (€)"
type="number"
min="0"
step="0.01"
value={item.cost_per_unit.toString()}
onChange={(e) => handleUpdateItem(
item.id,
'cost_per_unit',
Number(e.target.value)
)}
size="sm"
/>
<Input
label="Días de Caducidad"
type="number"
min="1"
value={item.expiration_days.toString()}
onChange={(e) => handleUpdateItem(
item.id,
'expiration_days',
Number(e.target.value)
)}
size="sm"
/>
</div>
)}
</div>
</div>
</div>
))}
</div>
{error && (
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
<p className="text-[var(--color-error)]">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={() => setShowInventoryStep(false)}
>
Volver
</Button>
<Button
onClick={handleCreateInventory}
isLoading={isCreating}
loadingText="Creando Inventario..."
size="lg"
disabled={selectedCount === 0}
>
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="text-center">
<p className="text-[var(--text-secondary)] mb-6">
Sube tus datos de ventas (formato CSV o JSON) para generar sugerencias de inventario inteligentes.
</p>
</div>
{/* File Upload Area */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
selectedFile
? 'border-[var(--color-success)] bg-[var(--color-success)]/5'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5'
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json"
onChange={handleFileSelect}
className="hidden"
/>
{selectedFile ? (
<div className="space-y-4">
<div className="text-[var(--color-success)]">
<svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-lg font-medium">Archivo Seleccionado</p>
<p className="text-[var(--text-secondary)]">{selectedFile.name}</p>
<p className="text-sm text-[var(--text-tertiary)]">
{formatFileSize(selectedFile.size)}
</p>
</div>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
Choose Different File
</Button>
</div>
) : (
<div className="space-y-4">
<svg className="mx-auto h-12 w-12 text-[var(--text-tertiary)]" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div>
<p className="text-lg font-medium">Drop your sales data here</p>
<p className="text-[var(--text-secondary)]">or click to browse files</p>
<p className="text-sm text-[var(--text-tertiary)] mt-2">
Supported formats: CSV, JSON (max 100MB)
</p>
</div>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
Choose File
</Button>
</div>
)}
</div>
{/* Progress */}
{progressState && (
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="flex justify-between text-sm mb-2">
<span className="font-medium">{progressState.message}</span>
<span>{progressState.progress}%</span>
</div>
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${progressState.progress}%` }}
/>
</div>
</div>
)}
{/* Validation Results */}
{validationResult && (
<div className="bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--color-success)] mb-2">Validation Successful!</h3>
<div className="space-y-2 text-sm">
<p>Total records: {validationResult.total_records}</p>
<p>Valid records: {validationResult.valid_records}</p>
{validationResult.invalid_records > 0 && (
<p className="text-[var(--color-warning)]">
Invalid records: {validationResult.invalid_records}
</p>
)}
{validationResult.warnings && validationResult.warnings.length > 0 && (
<div className="mt-2">
<p className="font-medium text-[var(--color-warning)]">Warnings:</p>
<ul className="list-disc list-inside">
{validationResult.warnings.map((warning, index) => (
<li key={index} className="text-[var(--color-warning)]">{warning}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* Error */}
{error && (
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
<p className="text-[var(--color-error)]">{error}</p>
</div>
)}
{/* Actions */}
<div className="flex justify-between">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Previous
</Button>
<div className="space-x-3">
{selectedFile && !validationResult && (
<Button
onClick={handleValidateFile}
isLoading={isValidating}
loadingText="Validating..."
>
Validate File
</Button>
)}
{validationResult && (
<Button
onClick={handleContinue}
size="lg"
>
Continue with This Data
</Button>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,4 @@
export { RegisterTenantStep } from './RegisterTenantStep';
export { UploadSalesDataStep } from './UploadSalesDataStep';
export { MLTrainingStep } from './MLTrainingStep';
export { CompletionStep } from './CompletionStep';