Simplify the onboardinf flow components
This commit is contained in:
@@ -289,4 +289,77 @@ export const useValidateAndImportFile = () => {
|
||||
isLoading: validateCsv.isPending || validateJson.isPending || importCsv.isPending || importJson.isPending,
|
||||
error: validateCsv.error || validateJson.error || importCsv.error || importJson.error,
|
||||
};
|
||||
};
|
||||
|
||||
// Import-only hook (for when validation has already been done)
|
||||
export const useImportFileOnly = () => {
|
||||
const importCsv = useImportCsvFile();
|
||||
const importJson = useImportJsonData();
|
||||
|
||||
const importFile = async (
|
||||
tenantId: string,
|
||||
file: File,
|
||||
options?: {
|
||||
chunkSize?: number;
|
||||
onProgress?: (stage: string, progress: number, message: string) => void;
|
||||
}
|
||||
): Promise<{
|
||||
importResult?: ImportProcessResponse;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
options?.onProgress?.('importing', 10, 'Iniciando importación de datos...');
|
||||
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase();
|
||||
let importResult: ImportProcessResponse;
|
||||
|
||||
if (fileExtension === 'csv') {
|
||||
importResult = await importCsv.mutateAsync({
|
||||
tenantId,
|
||||
file,
|
||||
options: {
|
||||
skip_validation: true, // Skip validation since already done
|
||||
chunk_size: options?.chunkSize
|
||||
}
|
||||
});
|
||||
} else if (fileExtension === 'json') {
|
||||
const jsonData = await file.text().then(text => JSON.parse(text));
|
||||
importResult = await importJson.mutateAsync({
|
||||
tenantId,
|
||||
data: jsonData,
|
||||
options: {
|
||||
skip_validation: true, // Skip validation since already done
|
||||
chunk_size: options?.chunkSize
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('Formato de archivo no soportado. Use CSV o JSON.');
|
||||
}
|
||||
|
||||
options?.onProgress?.('completed', 100,
|
||||
`Importación completada: ${importResult.records_processed} registros procesados`
|
||||
);
|
||||
|
||||
return {
|
||||
importResult,
|
||||
success: importResult.success,
|
||||
error: importResult.success ? undefined : (importResult.errors?.join(', ') || 'Error en la importación'),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error importando archivo';
|
||||
options?.onProgress?.('error', 0, errorMessage);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
importFile,
|
||||
isImporting: importCsv.isPending || importJson.isPending,
|
||||
error: importCsv.error || importJson.error,
|
||||
};
|
||||
};
|
||||
@@ -4,19 +4,12 @@
|
||||
|
||||
export interface BakeryRegistration {
|
||||
name: string;
|
||||
business_type?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
address: string;
|
||||
postal_code: string;
|
||||
phone: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postal_code?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
subdomain?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
business_type?: string;
|
||||
business_model?: string;
|
||||
}
|
||||
|
||||
export interface TenantResponse {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
4
frontend/src/components/domain/onboarding/steps/index.ts
Normal file
4
frontend/src/components/domain/onboarding/steps/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { RegisterTenantStep } from './RegisterTenantStep';
|
||||
export { UploadSalesDataStep } from './UploadSalesDataStep';
|
||||
export { MLTrainingStep } from './MLTrainingStep';
|
||||
export { CompletionStep } from './CompletionStep';
|
||||
@@ -1,225 +0,0 @@
|
||||
# Onboarding Hooks - Complete Clean Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This onboarding system has been **completely refactored** with no legacy code or backwards compatibility. It follows modern clean architecture principles with:
|
||||
|
||||
- **Standardized patterns** across all services
|
||||
- **Zero circular dependencies**
|
||||
- **Centralized state management** with Zustand
|
||||
- **Type-safe interfaces** throughout
|
||||
- **Service isolation** with standardized APIs
|
||||
- **Factory pattern** for service hooks
|
||||
|
||||
## Architecture
|
||||
|
||||
### 🏗️ Core Layer (`core/`)
|
||||
|
||||
**`store.ts`** - Centralized Zustand store
|
||||
- Single source of truth for all onboarding state
|
||||
- Atomic operations with devtools integration
|
||||
- Computed getters for derived state
|
||||
|
||||
**`actions.ts`** - Business logic orchestration
|
||||
- Coordinates between services
|
||||
- Handles complex step transitions
|
||||
- Manages validation and error states
|
||||
|
||||
**`types.ts`** - Complete type definitions
|
||||
- Unified interfaces for all services
|
||||
- Standardized error and state patterns
|
||||
- Re-exports for external consumption
|
||||
|
||||
### 🔧 Services Layer (`services/`)
|
||||
|
||||
All services follow the same standardized pattern using `createServiceHook`:
|
||||
|
||||
**`useTenantCreation.ts`** - Bakery registration and tenant setup
|
||||
**`useSalesProcessing.ts`** - File validation and AI classification
|
||||
**`useInventorySetup.ts`** - Inventory creation and sales import
|
||||
**`useTrainingOrchestration.ts`** - ML model training workflow
|
||||
**`useProgressTracking.ts`** - Backend progress synchronization
|
||||
**`useResumeLogic.ts`** - Flow resumption management
|
||||
|
||||
### 🛠️ Utils Layer (`utils/`)
|
||||
|
||||
**`createServiceHook.ts`** - Factory for standardized service hooks
|
||||
- Eliminates duplicate patterns
|
||||
- Provides consistent async execution
|
||||
- Standardized error handling
|
||||
|
||||
## New File Structure
|
||||
|
||||
```
|
||||
src/hooks/business/onboarding/
|
||||
├── core/
|
||||
│ ├── store.ts # Zustand centralized store
|
||||
│ ├── actions.ts # Business logic orchestration
|
||||
│ ├── types.ts # Complete type definitions
|
||||
│ └── useAutoResume.ts # Auto-resume wrapper
|
||||
├── services/
|
||||
│ ├── useTenantCreation.ts # Tenant service
|
||||
│ ├── useSalesProcessing.ts # Sales processing service
|
||||
│ ├── useInventorySetup.ts # Inventory service
|
||||
│ ├── useTrainingOrchestration.ts # Training service
|
||||
│ ├── useProgressTracking.ts # Progress service
|
||||
│ └── useResumeLogic.ts # Resume service
|
||||
├── utils/
|
||||
│ └── createServiceHook.ts # Service factory
|
||||
├── config/
|
||||
│ └── steps.ts # Step definitions and validation
|
||||
├── useOnboarding.ts # Main unified hook
|
||||
└── index.ts # Clean exports
|
||||
```
|
||||
|
||||
## Component Usage
|
||||
|
||||
### Primary Interface - useOnboarding
|
||||
|
||||
```typescript
|
||||
import { useOnboarding } from '../hooks/business/onboarding';
|
||||
|
||||
const OnboardingComponent = () => {
|
||||
const {
|
||||
// Core state
|
||||
currentStep,
|
||||
steps,
|
||||
data,
|
||||
progress,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Service states (when needed)
|
||||
tenantCreation: { isLoading: tenantLoading, isSuccess },
|
||||
salesProcessing: { stage, progress: fileProgress, suggestions },
|
||||
inventorySetup: { createdItems, inventoryMapping },
|
||||
trainingOrchestration: { status, logs, metrics },
|
||||
|
||||
// Actions
|
||||
nextStep,
|
||||
previousStep,
|
||||
updateStepData,
|
||||
createTenant,
|
||||
processSalesFile,
|
||||
startTraining,
|
||||
completeOnboarding,
|
||||
clearError,
|
||||
} = useOnboarding();
|
||||
|
||||
// Clean, consistent API across all functionality
|
||||
};
|
||||
```
|
||||
|
||||
### Auto-Resume Functionality
|
||||
|
||||
```typescript
|
||||
import { useAutoResume } from '../hooks/business/onboarding';
|
||||
|
||||
const OnboardingPage = () => {
|
||||
const { isCheckingResume, completionPercentage } = useAutoResume();
|
||||
|
||||
if (isCheckingResume) {
|
||||
return <LoadingSpinner message="Checking saved progress..." />;
|
||||
}
|
||||
|
||||
// Continue with onboarding flow
|
||||
};
|
||||
```
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### ✅ **Eliminated All Legacy Issues**
|
||||
|
||||
1. **No Circular Dependencies** - Clear dependency hierarchy
|
||||
2. **No Massive God Hooks** - Focused, single-responsibility services
|
||||
3. **No Inconsistent Patterns** - Standardized service factory
|
||||
4. **No Type Confusion** - Clean, unified type system
|
||||
5. **No Duplicate Code** - DRY principles throughout
|
||||
|
||||
### ✅ **Modern Patterns**
|
||||
|
||||
1. **Zustand Store** - Performant, devtools-enabled state
|
||||
2. **Factory Pattern** - Consistent service creation
|
||||
3. **Service Composition** - Clean separation of concerns
|
||||
4. **Type-Safe** - Full TypeScript coverage
|
||||
5. **Async-First** - Proper error handling and loading states
|
||||
|
||||
### ✅ **Developer Experience**
|
||||
|
||||
1. **Predictable API** - Same patterns across all services
|
||||
2. **Easy Testing** - Isolated, mockable services
|
||||
3. **Clear Documentation** - Self-documenting code structure
|
||||
4. **Performance** - Optimized renders and state updates
|
||||
5. **Debugging** - Zustand devtools integration
|
||||
|
||||
## Benefits for Components
|
||||
|
||||
### Before (Legacy)
|
||||
```typescript
|
||||
// Inconsistent APIs, complex imports, circular deps
|
||||
import { useOnboarding } from './useOnboarding';
|
||||
import { useAutoResume } from './useAutoResume'; // Circular dependency!
|
||||
|
||||
const {
|
||||
// 50+ properties mixed together
|
||||
currentStep, data, isLoading, tenantCreation: { isLoading: tenantLoading },
|
||||
salesProcessing: { stage, progress }, // Nested complexity
|
||||
} = useOnboarding();
|
||||
```
|
||||
|
||||
### After (Clean)
|
||||
```typescript
|
||||
// Clean, consistent, predictable
|
||||
import { useOnboarding, useAutoResume } from '../hooks/business/onboarding';
|
||||
|
||||
const onboarding = useOnboarding();
|
||||
const autoResume = useAutoResume();
|
||||
|
||||
// Clear separation, no circular deps, standardized patterns
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
### ❌ Removed (No Backwards Compatibility)
|
||||
- Old `useOnboarding` (400+ lines)
|
||||
- Old `useOnboardingData`
|
||||
- Old `useOnboardingFlow`
|
||||
- Old `useAutoResume`
|
||||
- All inconsistent service hooks
|
||||
|
||||
### ✅ New Clean Interfaces
|
||||
- Unified `useOnboarding` hook
|
||||
- Standardized service hooks
|
||||
- Centralized store management
|
||||
- Factory-created services
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Direct Service Access
|
||||
```typescript
|
||||
import { useSalesProcessing, useInventorySetup } from '../hooks/business/onboarding';
|
||||
|
||||
// When you need direct service control
|
||||
const salesService = useSalesProcessing();
|
||||
const inventoryService = useInventorySetup();
|
||||
```
|
||||
|
||||
### Custom Service Creation
|
||||
```typescript
|
||||
import { createServiceHook } from '../hooks/business/onboarding';
|
||||
|
||||
// Create new services following the same pattern
|
||||
const useMyCustomService = createServiceHook({
|
||||
initialState: { customData: null },
|
||||
// ... configuration
|
||||
});
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- **Zustand** - Minimal re-renders, optimized updates
|
||||
- **Memoized Selectors** - Computed values cached
|
||||
- **Service Isolation** - Independent loading states
|
||||
- **Factory Pattern** - Reduced bundle size
|
||||
|
||||
This is a **complete rewrite** with zero legacy baggage. Modern, maintainable, and built for the future.
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* Onboarding step definitions and validation logic
|
||||
*/
|
||||
|
||||
import type { OnboardingStep, OnboardingData } from '../core/types';
|
||||
|
||||
export const DEFAULT_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: 'setup',
|
||||
title: '🏢 Setup',
|
||||
description: 'Configuración básica de tu panadería y creación del tenant',
|
||||
isRequired: true,
|
||||
isCompleted: false,
|
||||
validation: (data: OnboardingData) => {
|
||||
if (!data.bakery?.name) return 'El nombre de la panadería es requerido';
|
||||
if (!data.bakery?.business_type) return 'El tipo de negocio es requerido';
|
||||
if (!data.bakery?.address) return 'La dirección es requerida';
|
||||
if (!data.bakery?.city) return 'La ciudad es requerida';
|
||||
if (!data.bakery?.postal_code) return 'El código postal es requerido';
|
||||
if (!data.bakery?.phone) return 'El teléfono es requerido';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'smart-inventory-setup',
|
||||
title: '📦 Inventario Inteligente',
|
||||
description: 'Sube datos de ventas, configura inventario y crea tu catálogo de productos',
|
||||
isRequired: true,
|
||||
isCompleted: false,
|
||||
validation: (data: OnboardingData) => {
|
||||
console.log('Smart Inventory Step Validation - Data:', {
|
||||
hasSalesData: !!data.files?.salesData,
|
||||
processingStage: data.processingStage,
|
||||
isValid: data.processingResults?.is_valid,
|
||||
reviewCompleted: data.reviewCompleted,
|
||||
approvedProductsCount: data.approvedProducts?.length || 0,
|
||||
inventoryConfigured: data.inventoryConfigured,
|
||||
salesImportResult: data.salesImportResult
|
||||
});
|
||||
|
||||
if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas';
|
||||
if (data.processingStage !== 'completed' && data.processingStage !== 'review') return 'El procesamiento debe completarse antes de continuar';
|
||||
if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar';
|
||||
if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados';
|
||||
const hasApprovedProducts = data.approvedProducts && data.approvedProducts.length > 0;
|
||||
if (!hasApprovedProducts) return 'Debes aprobar al menos un producto para continuar';
|
||||
|
||||
// Check if ready for automatic inventory creation and sales import
|
||||
// If inventory is already configured, check if sales data was imported
|
||||
if (data.inventoryConfigured) {
|
||||
const hasImportResults = data.salesImportResult &&
|
||||
(data.salesImportResult.records_created > 0 ||
|
||||
data.salesImportResult.success === true ||
|
||||
data.salesImportResult.imported === true);
|
||||
|
||||
if (!hasImportResults) {
|
||||
console.log('Smart Inventory Step Validation - Sales import validation failed:', {
|
||||
hasSalesImportResult: !!data.salesImportResult,
|
||||
salesImportResult: data.salesImportResult,
|
||||
inventoryConfigured: data.inventoryConfigured
|
||||
});
|
||||
|
||||
return 'Los datos de ventas históricos deben estar importados para continuar al entrenamiento de IA.';
|
||||
}
|
||||
} else {
|
||||
// If inventory is not configured yet, ensure all prerequisites are ready
|
||||
// The actual creation will happen automatically on "Next Step"
|
||||
console.log('Smart Inventory Step Validation - Ready for automatic inventory creation:', {
|
||||
hasApprovedProducts: hasApprovedProducts,
|
||||
reviewCompleted: data.reviewCompleted,
|
||||
readyForCreation: hasApprovedProducts && data.reviewCompleted
|
||||
});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'suppliers',
|
||||
title: '🏪 Proveedores',
|
||||
description: 'Configuración de proveedores y asociaciones',
|
||||
isRequired: false,
|
||||
isCompleted: false,
|
||||
// Optional step - no strict validation required
|
||||
},
|
||||
{
|
||||
id: 'ml-training',
|
||||
title: '🎯 Inteligencia',
|
||||
description: 'Creación de tu asistente inteligente personalizado',
|
||||
isRequired: true,
|
||||
isCompleted: false,
|
||||
validation: (data: OnboardingData) => {
|
||||
console.log('ML Training Step Validation - Data:', {
|
||||
inventoryConfigured: data.inventoryConfigured,
|
||||
hasSalesFile: !!data.files?.salesData,
|
||||
processingResults: data.processingResults,
|
||||
salesImportResult: data.salesImportResult,
|
||||
trainingStatus: data.trainingStatus
|
||||
});
|
||||
|
||||
// CRITICAL PREREQUISITE 1: Inventory must be configured
|
||||
if (!data.inventoryConfigured) {
|
||||
return 'Debes configurar el inventario antes de entrenar el modelo de IA';
|
||||
}
|
||||
|
||||
// CRITICAL PREREQUISITE 2: Sales file must be uploaded and processed
|
||||
if (!data.files?.salesData) {
|
||||
return 'Debes cargar un archivo de datos de ventas históricos para entrenar el modelo';
|
||||
}
|
||||
|
||||
// CRITICAL PREREQUISITE 3: Sales data must be processed and valid
|
||||
if (!data.processingResults?.is_valid) {
|
||||
return 'Los datos de ventas deben ser procesados y validados antes del entrenamiento';
|
||||
}
|
||||
|
||||
// CRITICAL PREREQUISITE 4: Sales data must be imported to backend
|
||||
const hasSalesDataImported = data.salesImportResult &&
|
||||
(data.salesImportResult.records_created > 0 ||
|
||||
data.salesImportResult.success === true);
|
||||
|
||||
if (!hasSalesDataImported && data.trainingStatus !== 'completed') {
|
||||
return 'Los datos de ventas históricos deben estar importados en el sistema para iniciar el entrenamiento del modelo de IA';
|
||||
}
|
||||
|
||||
// CRITICAL PREREQUISITE 5: Training must be completed to proceed
|
||||
if (data.trainingStatus !== 'completed') {
|
||||
return 'El entrenamiento del modelo de IA debe completarse antes de continuar';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'completion',
|
||||
title: '🎉 Listo',
|
||||
description: 'Finalización y preparación para usar la plataforma',
|
||||
isRequired: true,
|
||||
isCompleted: false,
|
||||
// Completion step - no additional validation needed
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get step by ID
|
||||
*/
|
||||
export const getStepById = (stepId: string): OnboardingStep | undefined => {
|
||||
return DEFAULT_STEPS.find(step => step.id === stepId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get step index by ID
|
||||
*/
|
||||
export const getStepIndex = (stepId: string): number => {
|
||||
return DEFAULT_STEPS.findIndex(step => step.id === stepId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if all required steps before given step are completed
|
||||
*/
|
||||
export const canAccessStep = (stepIndex: number, completedSteps: boolean[]): boolean => {
|
||||
for (let i = 0; i < stepIndex; i++) {
|
||||
if (DEFAULT_STEPS[i].isRequired && !completedSteps[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate onboarding progress
|
||||
*/
|
||||
export const calculateProgress = (completedSteps: boolean[]): {
|
||||
completedCount: number;
|
||||
totalRequired: number;
|
||||
percentage: number;
|
||||
} => {
|
||||
const requiredSteps = DEFAULT_STEPS.filter(step => step.isRequired);
|
||||
const completedRequired = requiredSteps.filter((step) => {
|
||||
const stepIndex = getStepIndex(step.id);
|
||||
return completedSteps[stepIndex];
|
||||
});
|
||||
|
||||
return {
|
||||
completedCount: completedRequired.length,
|
||||
totalRequired: requiredSteps.length,
|
||||
percentage: Math.round((completedRequired.length / requiredSteps.length) * 100),
|
||||
};
|
||||
};
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* Core onboarding actions - Business logic orchestration
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOnboardingStore } from './store';
|
||||
import { useTenantCreation } from '../services/useTenantCreation';
|
||||
import { useSalesProcessing } from '../services/useSalesProcessing';
|
||||
import { useInventorySetup } from '../services/useInventorySetup';
|
||||
import { useTrainingOrchestration } from '../services/useTrainingOrchestration';
|
||||
import { useProgressTracking } from '../services/useProgressTracking';
|
||||
import { getStepById } from '../config/steps';
|
||||
import type { ProductSuggestionResponse } from '../core/types';
|
||||
import type { BakeryRegistration } from '../../../../api';
|
||||
|
||||
export const useOnboardingActions = () => {
|
||||
const navigate = useNavigate();
|
||||
const store = useOnboardingStore();
|
||||
|
||||
// Service hooks
|
||||
const tenantCreation = useTenantCreation();
|
||||
const salesProcessing = useSalesProcessing();
|
||||
const inventorySetup = useInventorySetup();
|
||||
const trainingOrchestration = useTrainingOrchestration();
|
||||
const progressTracking = useProgressTracking();
|
||||
|
||||
const validateCurrentStep = useCallback((): string | null => {
|
||||
const currentStep = store.getCurrentStep();
|
||||
if (!currentStep) return null;
|
||||
|
||||
const stepConfig = getStepById(currentStep.id);
|
||||
if (stepConfig?.validation) {
|
||||
return stepConfig.validation(store.data);
|
||||
}
|
||||
return null;
|
||||
}, [store]);
|
||||
|
||||
const nextStep = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const currentStep = store.getCurrentStep();
|
||||
|
||||
if (!currentStep) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate current step
|
||||
const validation = validateCurrentStep();
|
||||
|
||||
if (validation) {
|
||||
store.setError(validation);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setError(null);
|
||||
|
||||
// Handle step-specific actions before moving to next step
|
||||
if (currentStep.id === 'setup') {
|
||||
// IMPORTANT: Ensure user_registered step is completed first
|
||||
const userRegisteredCompleted = await progressTracking.markStepCompleted('user_registered', {});
|
||||
if (!userRegisteredCompleted) {
|
||||
console.error('❌ Failed to mark user_registered as completed');
|
||||
store.setError('Failed to verify user registration status');
|
||||
return false;
|
||||
}
|
||||
|
||||
const stepData = store.getStepData('setup');
|
||||
const bakeryData = stepData.bakery;
|
||||
|
||||
// Check if tenant creation is needed
|
||||
const needsTenantCreation = bakeryData && !bakeryData.tenantCreated && !bakeryData.tenant_id;
|
||||
|
||||
if (needsTenantCreation) {
|
||||
store.setLoading(true);
|
||||
const success = await tenantCreation.createTenant(bakeryData);
|
||||
store.setLoading(false);
|
||||
|
||||
if (!success) {
|
||||
store.setError(tenantCreation.error || 'Error creating tenant');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Tenant created successfully');
|
||||
|
||||
// Wait a moment for backend to update state
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStep.id === 'smart-inventory-setup') {
|
||||
const inventoryData = store.getStepData('smart-inventory-setup');
|
||||
|
||||
if (inventoryData?.approvedProducts?.length > 0 && !inventoryData?.inventoryConfigured) {
|
||||
store.setLoading(true);
|
||||
|
||||
// Create inventory from approved products
|
||||
const inventoryResult = await inventorySetup.createInventoryFromSuggestions(inventoryData.approvedProducts);
|
||||
|
||||
if (!inventoryResult.success) {
|
||||
store.setLoading(false);
|
||||
store.setError(inventorySetup.error || 'Error creating inventory');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Import sales data after inventory creation
|
||||
if (inventoryData?.processingResults && inventoryResult?.inventoryMapping) {
|
||||
const salesImportResult = await inventorySetup.importSalesData(
|
||||
inventoryData.processingResults,
|
||||
inventoryResult.inventoryMapping
|
||||
);
|
||||
|
||||
if (!salesImportResult.success) {
|
||||
store.setLoading(false);
|
||||
store.setError(inventorySetup.error || 'Error importing sales data');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
store.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Save progress to backend
|
||||
const stepData = store.getStepData(currentStep.id);
|
||||
|
||||
const markCompleted = await progressTracking.markStepCompleted(currentStep.id, stepData);
|
||||
if (!markCompleted) {
|
||||
console.error(`❌ Failed to mark step "${currentStep.id}" as completed`);
|
||||
store.setError(`Failed to save progress for step "${currentStep.id}"`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Move to next step
|
||||
if (store.nextStep()) {
|
||||
store.markStepCompleted(store.currentStep - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error in nextStep:', error);
|
||||
store.setError(error instanceof Error ? error.message : 'Error moving to next step');
|
||||
store.setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [store, validateCurrentStep, tenantCreation, inventorySetup, progressTracking]);
|
||||
|
||||
const previousStep = useCallback((): boolean => {
|
||||
return store.previousStep();
|
||||
}, [store]);
|
||||
|
||||
const goToStep = useCallback((stepIndex: number): boolean => {
|
||||
return store.goToStep(stepIndex);
|
||||
}, [store]);
|
||||
|
||||
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
||||
store.setLoading(true);
|
||||
const success = await tenantCreation.createTenant(bakeryData);
|
||||
store.setLoading(false);
|
||||
|
||||
if (!success) {
|
||||
store.setError(tenantCreation.error || 'Error creating tenant');
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [store, tenantCreation]);
|
||||
|
||||
const processSalesFile = useCallback(async (file: File): Promise<boolean> => {
|
||||
console.log('🎬 Actions - processSalesFile started');
|
||||
store.setLoading(true);
|
||||
|
||||
const result = await salesProcessing.processFile(file);
|
||||
console.log('🎬 Actions - processFile result:', result);
|
||||
|
||||
store.setLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('❌ Actions - Processing failed:', salesProcessing.error);
|
||||
store.setError(salesProcessing.error || 'Error processing sales file');
|
||||
} else {
|
||||
console.log('✅ Actions - Processing succeeded');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, [store, salesProcessing]);
|
||||
|
||||
const generateProductSuggestions = useCallback(async (productList: string[]): Promise<boolean> => {
|
||||
console.log('🎬 Actions - generateProductSuggestions started for', productList.length, 'products');
|
||||
store.setLoading(true);
|
||||
|
||||
const result = await salesProcessing.generateProductSuggestions(productList);
|
||||
console.log('🎬 Actions - generateProductSuggestions result:', result);
|
||||
|
||||
store.setLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('❌ Actions - Suggestions generation failed:', result.error);
|
||||
store.setError(result.error || 'Error generating product suggestions');
|
||||
} else {
|
||||
console.log('✅ Actions - Product suggestions generated successfully');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, [store, salesProcessing]);
|
||||
|
||||
const createInventoryFromSuggestions = useCallback(async (
|
||||
suggestions: ProductSuggestionResponse[]
|
||||
): Promise<boolean> => {
|
||||
store.setLoading(true);
|
||||
const result = await inventorySetup.createInventoryFromSuggestions(suggestions);
|
||||
store.setLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
store.setError(inventorySetup.error || 'Error creating inventory');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, [store, inventorySetup]);
|
||||
|
||||
const importSalesData = useCallback(async (
|
||||
salesData: any,
|
||||
inventoryMapping: { [productName: string]: string }
|
||||
): Promise<boolean> => {
|
||||
store.setLoading(true);
|
||||
const result = await inventorySetup.importSalesData(salesData, inventoryMapping);
|
||||
store.setLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
store.setError(inventorySetup.error || 'Error importing sales data');
|
||||
}
|
||||
|
||||
return result.success;
|
||||
}, [store, inventorySetup]);
|
||||
|
||||
const startTraining = useCallback(async (options?: {
|
||||
products?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<boolean> => {
|
||||
// Validate training prerequisites
|
||||
const validation = await trainingOrchestration.validateTrainingData(store.getAllStepData());
|
||||
if (!validation.isValid) {
|
||||
store.setError(`Training prerequisites not met: ${validation.missingItems.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
store.setLoading(true);
|
||||
const success = await trainingOrchestration.startTraining(options);
|
||||
store.setLoading(false);
|
||||
|
||||
if (!success) {
|
||||
store.setError(trainingOrchestration.error || 'Error starting training');
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [store, trainingOrchestration]);
|
||||
|
||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
store.setLoading(true);
|
||||
|
||||
// Mark final completion
|
||||
const completionStats = {
|
||||
totalProducts: store.data.processingResults?.unique_products || 0,
|
||||
inventoryItems: store.data.inventoryItems?.length || 0,
|
||||
suppliersConfigured: store.data.suppliers?.length || 0,
|
||||
mlModelAccuracy: store.data.trainingMetrics?.accuracy || 0,
|
||||
estimatedTimeSaved: '2-3 horas por día',
|
||||
completionScore: 95,
|
||||
};
|
||||
|
||||
store.setStepData('completion', { completionStats });
|
||||
|
||||
// Complete in backend
|
||||
const success = await progressTracking.completeOnboarding();
|
||||
|
||||
store.setLoading(false);
|
||||
|
||||
if (success) {
|
||||
console.log('✅ Onboarding completed');
|
||||
store.markStepCompleted(store.steps.length - 1);
|
||||
|
||||
// Navigate to dashboard after completion
|
||||
setTimeout(() => {
|
||||
navigate('/app/dashboard');
|
||||
}, 2000);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
store.setError('Error completing onboarding');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error completing onboarding:', error);
|
||||
store.setError(error instanceof Error ? error.message : 'Error completing onboarding');
|
||||
store.setLoading(false);
|
||||
return false;
|
||||
}
|
||||
}, [store, progressTracking, navigate]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
store.setError(null);
|
||||
tenantCreation.clearError();
|
||||
salesProcessing.clearError();
|
||||
inventorySetup.clearError();
|
||||
trainingOrchestration.clearError();
|
||||
progressTracking.clearError();
|
||||
}, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration, progressTracking]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
store.reset();
|
||||
tenantCreation.reset();
|
||||
salesProcessing.reset();
|
||||
inventorySetup.reset();
|
||||
trainingOrchestration.reset();
|
||||
}, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]);
|
||||
|
||||
return {
|
||||
// Navigation actions
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToStep,
|
||||
validateCurrentStep,
|
||||
|
||||
// Step-specific actions
|
||||
createTenant,
|
||||
processSalesFile,
|
||||
generateProductSuggestions, // New function for separated suggestion generation
|
||||
createInventoryFromSuggestions,
|
||||
importSalesData,
|
||||
startTraining,
|
||||
completeOnboarding,
|
||||
|
||||
// Utility actions
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
/**
|
||||
* Onboarding store - Centralized state management with Zustand
|
||||
* Handles all onboarding data, steps, and global state
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { OnboardingData, OnboardingStep, OnboardingProgress } from './types';
|
||||
import { DEFAULT_STEPS } from '../config/steps';
|
||||
|
||||
interface OnboardingStore {
|
||||
// Flow state
|
||||
currentStep: number;
|
||||
steps: OnboardingStep[];
|
||||
|
||||
// Data state
|
||||
data: OnboardingData;
|
||||
|
||||
// UI state
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Progress state
|
||||
progress: OnboardingProgress | null;
|
||||
|
||||
// Actions - Flow management
|
||||
setCurrentStep: (step: number) => void;
|
||||
nextStep: () => boolean;
|
||||
previousStep: () => boolean;
|
||||
goToStep: (stepIndex: number) => boolean;
|
||||
markStepCompleted: (stepIndex: number) => void;
|
||||
|
||||
// Actions - Data management
|
||||
setStepData: (stepId: string, stepData: Partial<OnboardingData>) => void;
|
||||
clearStepData: (stepId: string) => void;
|
||||
|
||||
// Actions - UI state
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
|
||||
// Actions - Progress
|
||||
setProgress: (progress: OnboardingProgress) => void;
|
||||
|
||||
// Actions - Utilities
|
||||
reset: () => void;
|
||||
|
||||
// Getters
|
||||
getCurrentStep: () => OnboardingStep;
|
||||
getStepData: (stepId: string) => any;
|
||||
getAllStepData: () => { [stepId: string]: any };
|
||||
getProgress: () => OnboardingProgress;
|
||||
canNavigateToStep: (stepIndex: number) => boolean;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
currentStep: 0,
|
||||
steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })),
|
||||
data: { allStepData: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
progress: null,
|
||||
};
|
||||
|
||||
// Debug logging for store initialization (only if there's an issue)
|
||||
if (initialState.steps.length !== DEFAULT_STEPS.length) {
|
||||
console.error('⚠️ Store initialization issue: steps count mismatch');
|
||||
}
|
||||
|
||||
export const useOnboardingStore = create<OnboardingStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Flow management actions
|
||||
setCurrentStep: (step: number) => {
|
||||
set({ currentStep: step }, false, 'setCurrentStep');
|
||||
},
|
||||
|
||||
nextStep: () => {
|
||||
const { currentStep, steps } = get();
|
||||
if (currentStep < steps.length - 1) {
|
||||
set({ currentStep: currentStep + 1 }, false, 'nextStep');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
previousStep: () => {
|
||||
const { currentStep } = get();
|
||||
if (currentStep > 0) {
|
||||
set({ currentStep: currentStep - 1 }, false, 'previousStep');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
goToStep: (stepIndex: number) => {
|
||||
const { steps, canNavigateToStep } = get();
|
||||
if (stepIndex >= 0 && stepIndex < steps.length && canNavigateToStep(stepIndex)) {
|
||||
set({ currentStep: stepIndex }, false, 'goToStep');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
markStepCompleted: (stepIndex: number) => {
|
||||
set((state) => ({
|
||||
steps: state.steps.map((step, index) =>
|
||||
index === stepIndex ? { ...step, isCompleted: true } : step
|
||||
),
|
||||
}), false, 'markStepCompleted');
|
||||
},
|
||||
|
||||
// Data management actions
|
||||
setStepData: (stepId: string, stepData: Partial<OnboardingData>) => {
|
||||
set((state) => ({
|
||||
data: {
|
||||
...state.data,
|
||||
...stepData,
|
||||
allStepData: {
|
||||
...state.data.allStepData,
|
||||
[stepId]: {
|
||||
...state.data.allStepData?.[stepId],
|
||||
...stepData,
|
||||
},
|
||||
},
|
||||
},
|
||||
}), false, 'setStepData');
|
||||
},
|
||||
|
||||
clearStepData: (stepId: string) => {
|
||||
set((state) => ({
|
||||
data: {
|
||||
...state.data,
|
||||
allStepData: {
|
||||
...state.data.allStepData,
|
||||
[stepId]: undefined,
|
||||
},
|
||||
},
|
||||
}), false, 'clearStepData');
|
||||
},
|
||||
|
||||
// UI state actions
|
||||
setLoading: (loading: boolean) => {
|
||||
set({ isLoading: loading }, false, 'setLoading');
|
||||
},
|
||||
|
||||
setError: (error: string | null) => {
|
||||
set({ error, isLoading: false }, false, 'setError');
|
||||
},
|
||||
|
||||
// Progress actions
|
||||
setProgress: (progress: OnboardingProgress) => {
|
||||
set({ progress }, false, 'setProgress');
|
||||
},
|
||||
|
||||
// Utility actions
|
||||
reset: () => {
|
||||
set(initialState, false, 'reset');
|
||||
},
|
||||
|
||||
// Getters
|
||||
getCurrentStep: () => {
|
||||
const { steps, currentStep } = get();
|
||||
return steps[currentStep];
|
||||
},
|
||||
|
||||
getStepData: (stepId: string) => {
|
||||
const { data } = get();
|
||||
return data.allStepData?.[stepId] || {};
|
||||
},
|
||||
|
||||
getAllStepData: () => {
|
||||
const { data } = get();
|
||||
return data.allStepData || {};
|
||||
},
|
||||
|
||||
getProgress: () => {
|
||||
const { steps, currentStep } = get();
|
||||
const completedSteps = steps.filter(step => step.isCompleted).length;
|
||||
const requiredSteps = steps.filter(step => step.isRequired).length;
|
||||
|
||||
return {
|
||||
currentStep,
|
||||
totalSteps: steps.length,
|
||||
completedSteps,
|
||||
isComplete: completedSteps === requiredSteps,
|
||||
progressPercentage: Math.round((completedSteps / requiredSteps) * 100),
|
||||
};
|
||||
},
|
||||
|
||||
canNavigateToStep: (stepIndex: number) => {
|
||||
const { steps } = get();
|
||||
// Allow navigation to any step for now - can add more complex logic later
|
||||
return stepIndex >= 0 && stepIndex < steps.length;
|
||||
},
|
||||
}),
|
||||
{ name: 'onboarding-store' }
|
||||
)
|
||||
);
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Core types for the entire onboarding system
|
||||
*/
|
||||
|
||||
import type {
|
||||
BakeryRegistration,
|
||||
TrainingJobResponse,
|
||||
UserProgress,
|
||||
} from '../../../../api';
|
||||
|
||||
// Re-export TrainingMetrics locally
|
||||
export interface TrainingMetrics {
|
||||
accuracy: number;
|
||||
mape: number;
|
||||
mae: number;
|
||||
rmse: number;
|
||||
r2_score: number;
|
||||
}
|
||||
|
||||
// Re-export ProductSuggestionResponse type locally since it's used across many files
|
||||
export interface ProductSuggestionResponse {
|
||||
suggestion_id: string;
|
||||
original_name: string;
|
||||
suggested_name: string;
|
||||
product_type: string;
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
confidence_score: number;
|
||||
estimated_shelf_life_days?: number;
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
is_seasonal: boolean;
|
||||
suggested_supplier?: string;
|
||||
notes?: string;
|
||||
sales_data?: {
|
||||
total_quantity: number;
|
||||
average_daily_sales: number;
|
||||
peak_day: string;
|
||||
frequency: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Base service state pattern
|
||||
export interface ServiceState<T = any> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isSuccess: boolean;
|
||||
}
|
||||
|
||||
// Base service actions pattern
|
||||
export interface ServiceActions {
|
||||
clearError: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// Onboarding step definitions
|
||||
export interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isRequired: boolean;
|
||||
isCompleted: boolean;
|
||||
validation?: (data: OnboardingData) => string | null;
|
||||
}
|
||||
|
||||
// Complete onboarding data structure
|
||||
export interface OnboardingData {
|
||||
// Step 1: Setup
|
||||
bakery?: BakeryRegistration;
|
||||
|
||||
// Step 2: Smart Inventory Setup
|
||||
files?: {
|
||||
salesData?: File;
|
||||
};
|
||||
processingStage?: 'upload' | 'validating' | 'validated' | 'analyzing' | 'review' | 'completed' | 'error';
|
||||
processingResults?: {
|
||||
is_valid: boolean;
|
||||
total_records: number;
|
||||
unique_products: number;
|
||||
product_list: string[];
|
||||
validation_errors: string[];
|
||||
validation_warnings: string[];
|
||||
summary: {
|
||||
date_range: string;
|
||||
total_sales: number;
|
||||
average_daily_sales: number;
|
||||
};
|
||||
};
|
||||
suggestions?: ProductSuggestionResponse[];
|
||||
detectedProducts?: any[];
|
||||
approvedSuggestions?: ProductSuggestionResponse[];
|
||||
approvedProducts?: ProductSuggestionResponse[];
|
||||
reviewCompleted?: boolean;
|
||||
inventoryItems?: any[];
|
||||
inventoryMapping?: { [productName: string]: string };
|
||||
inventoryConfigured?: boolean;
|
||||
salesImportResult?: {
|
||||
success: boolean;
|
||||
imported: boolean;
|
||||
records_created: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Step 3: Suppliers
|
||||
suppliers?: any[];
|
||||
supplierMappings?: any[];
|
||||
|
||||
// Step 4: ML Training
|
||||
trainingStatus?: 'idle' | 'validating' | 'training' | 'completed' | 'failed';
|
||||
trainingProgress?: number;
|
||||
trainingJob?: TrainingJobResponse;
|
||||
trainingLogs?: TrainingLog[];
|
||||
trainingMetrics?: TrainingMetrics;
|
||||
autoStartTraining?: boolean;
|
||||
|
||||
// Step 5: Completion
|
||||
completionStats?: {
|
||||
totalProducts: number;
|
||||
inventoryItems: number;
|
||||
suppliersConfigured: number;
|
||||
mlModelAccuracy: number;
|
||||
estimatedTimeSaved: string;
|
||||
completionScore: number;
|
||||
};
|
||||
|
||||
// Cross-step data sharing
|
||||
allStepData?: { [stepId: string]: any };
|
||||
}
|
||||
|
||||
export interface OnboardingProgress {
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
completedSteps: number;
|
||||
isComplete: boolean;
|
||||
progressPercentage: number;
|
||||
}
|
||||
|
||||
export interface OnboardingError {
|
||||
step?: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Training types
|
||||
export interface TrainingLog {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error' | 'success';
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
export type ProgressCallback = (progress: number, stage: string, message: string) => void;
|
||||
|
||||
// Step validation function
|
||||
export type StepValidator = (data: OnboardingData) => string | null;
|
||||
|
||||
// Service-specific state types
|
||||
export interface TenantCreationState extends ServiceState<BakeryRegistration> {
|
||||
tenantData: BakeryRegistration | null;
|
||||
}
|
||||
|
||||
export interface SalesProcessingState extends ServiceState<any> {
|
||||
stage: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error';
|
||||
progress: number;
|
||||
currentMessage: string;
|
||||
validationResults: any | null;
|
||||
suggestions: ProductSuggestionResponse[] | null;
|
||||
}
|
||||
|
||||
export interface InventorySetupState extends ServiceState<any> {
|
||||
createdItems: any[];
|
||||
inventoryMapping: { [productName: string]: string };
|
||||
salesImportResult: {
|
||||
success: boolean;
|
||||
imported: boolean;
|
||||
records_created: number;
|
||||
message: string;
|
||||
} | null;
|
||||
isInventoryConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface TrainingOrchestrationState extends ServiceState<TrainingJobResponse> {
|
||||
status: 'idle' | 'validating' | 'training' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
estimatedTimeRemaining: number;
|
||||
job: TrainingJobResponse | null;
|
||||
logs: TrainingLog[];
|
||||
metrics: TrainingMetrics | null;
|
||||
}
|
||||
|
||||
export interface ProgressTrackingState extends ServiceState<UserProgress> {
|
||||
progress: UserProgress | null;
|
||||
isInitialized: boolean;
|
||||
isCompleted: boolean;
|
||||
completionPercentage: number;
|
||||
currentBackendStep: string | null;
|
||||
}
|
||||
|
||||
export interface ResumeState extends ServiceState {
|
||||
isCheckingResume: boolean;
|
||||
resumePoint: { stepId: string; stepIndex: number } | null;
|
||||
shouldResume: boolean;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Auto-resume hook - Simple wrapper around resume logic service
|
||||
*/
|
||||
|
||||
import { useResumeLogic } from '../services/useResumeLogic';
|
||||
|
||||
export const useAutoResume = () => {
|
||||
const resumeLogic = useResumeLogic();
|
||||
|
||||
return {
|
||||
// State
|
||||
isCheckingResume: resumeLogic.isCheckingResume,
|
||||
isCompleted: resumeLogic.isCompleted,
|
||||
completionPercentage: resumeLogic.completionPercentage,
|
||||
|
||||
// Actions
|
||||
checkForSavedProgress: resumeLogic.checkForResume,
|
||||
resumeFromSavedProgress: resumeLogic.resumeFlow,
|
||||
};
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Onboarding hooks index - Complete clean architecture
|
||||
* No legacy code, no backwards compatibility, clean modern patterns
|
||||
*/
|
||||
|
||||
// Types (re-export core types for external usage)
|
||||
export type * from './core/types';
|
||||
|
||||
// Steps configuration
|
||||
export { DEFAULT_STEPS, getStepById, getStepIndex, canAccessStep, calculateProgress } from './config/steps';
|
||||
|
||||
// Core architecture (for advanced usage)
|
||||
export { useOnboardingStore } from './core/store';
|
||||
export { useOnboardingActions } from './core/actions';
|
||||
|
||||
// Service hooks (for direct service access when needed)
|
||||
export { useTenantCreation } from './services/useTenantCreation';
|
||||
export { useSalesProcessing } from './services/useSalesProcessing';
|
||||
export { useInventorySetup } from './services/useInventorySetup';
|
||||
export { useTrainingOrchestration } from './services/useTrainingOrchestration';
|
||||
export { useProgressTracking } from './services/useProgressTracking';
|
||||
export { useResumeLogic } from './services/useResumeLogic';
|
||||
|
||||
// Main hooks - PRIMARY INTERFACE for components
|
||||
export { useOnboarding } from './useOnboarding';
|
||||
export { useAutoResume } from './core/useAutoResume';
|
||||
|
||||
// Utility
|
||||
export { createServiceHook } from './utils/createServiceHook';
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Inventory setup service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
useCreateIngredient,
|
||||
useCreateSalesRecord,
|
||||
} from '../../../../api';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { useOnboardingStore } from '../core/store';
|
||||
import type { ProductSuggestionResponse } from '../core/types';
|
||||
|
||||
export const useInventorySetup = () => {
|
||||
const createIngredientMutation = useCreateIngredient();
|
||||
const createSalesRecordMutation = useCreateSalesRecord();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { setStepData } = useOnboardingStore();
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdItems, setCreatedItems] = useState<any[]>([]);
|
||||
const [inventoryMapping, setInventoryMapping] = useState<{ [productName: string]: string }>({});
|
||||
const [salesImportResult, setSalesImportResult] = useState<any | null>(null);
|
||||
const [isInventoryConfigured, setIsInventoryConfigured] = useState(false);
|
||||
|
||||
const createInventoryFromSuggestions = useCallback(async (
|
||||
suggestions: ProductSuggestionResponse[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
createdItems?: any[];
|
||||
inventoryMapping?: { [productName: string]: string };
|
||||
}> => {
|
||||
console.log('🔄 Creating inventory from suggestions:', suggestions?.length, 'items');
|
||||
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
console.error('❌ No suggestions provided');
|
||||
setError('No hay sugerencias para crear el inventario');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (!currentTenant?.id) {
|
||||
console.error('❌ No tenant ID available');
|
||||
setError('No se pudo obtener información del tenant');
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newCreatedItems = [];
|
||||
const newInventoryMapping: { [key: string]: string } = {};
|
||||
|
||||
// Create ingredients from approved suggestions
|
||||
for (const suggestion of suggestions) {
|
||||
try {
|
||||
const ingredientData = {
|
||||
name: suggestion.suggested_name || suggestion.original_name,
|
||||
category: suggestion.category || 'Sin categoría',
|
||||
description: suggestion.notes || '',
|
||||
unit_of_measure: suggestion.unit_of_measure || 'units',
|
||||
minimum_stock_level: 10,
|
||||
maximum_stock_level: 100,
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 15,
|
||||
reorder_quantity: 50,
|
||||
shelf_life_days: suggestion.estimated_shelf_life_days || 30,
|
||||
requires_refrigeration: suggestion.requires_refrigeration || false,
|
||||
requires_freezing: suggestion.requires_freezing || false,
|
||||
is_seasonal: suggestion.is_seasonal || false,
|
||||
cost_per_unit: 0,
|
||||
notes: suggestion.notes || `Producto clasificado automáticamente desde: ${suggestion.original_name}`,
|
||||
};
|
||||
|
||||
const createdItem = await createIngredientMutation.mutateAsync({
|
||||
tenantId: currentTenant.id,
|
||||
ingredientData,
|
||||
});
|
||||
|
||||
newCreatedItems.push(createdItem);
|
||||
// Map both original and suggested names to the same ingredient ID for flexibility
|
||||
newInventoryMapping[suggestion.original_name] = createdItem.id;
|
||||
if (suggestion.suggested_name && suggestion.suggested_name !== suggestion.original_name) {
|
||||
newInventoryMapping[suggestion.suggested_name] = createdItem.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error creating ingredient ${suggestion.suggested_name || suggestion.original_name}:`, error);
|
||||
// Continue with other ingredients even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
if (newCreatedItems.length === 0) {
|
||||
throw new Error('No se pudo crear ningún elemento del inventario');
|
||||
}
|
||||
|
||||
console.log('✅ Created', newCreatedItems.length, '/', suggestions.length, 'ingredients');
|
||||
|
||||
// Update state
|
||||
setCreatedItems(newCreatedItems);
|
||||
setInventoryMapping(newInventoryMapping);
|
||||
setIsInventoryConfigured(true);
|
||||
|
||||
// Update onboarding store
|
||||
setStepData('smart-inventory-setup', {
|
||||
inventoryItems: newCreatedItems,
|
||||
inventoryMapping: newInventoryMapping,
|
||||
inventoryConfigured: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
createdItems: newCreatedItems,
|
||||
inventoryMapping: newInventoryMapping,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error creating inventory';
|
||||
setError(errorMessage);
|
||||
return { success: false };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [createIngredientMutation, currentTenant, setStepData]);
|
||||
|
||||
const importSalesData = useCallback(async (
|
||||
salesData: any,
|
||||
inventoryMapping: { [productName: string]: string }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
recordsCreated: number;
|
||||
message: string;
|
||||
}> => {
|
||||
if (!currentTenant?.id) {
|
||||
setError('No se pudo obtener información del tenant');
|
||||
return {
|
||||
success: false,
|
||||
recordsCreated: 0,
|
||||
message: 'Error: No se pudo obtener información del tenant',
|
||||
};
|
||||
}
|
||||
|
||||
if (!salesData || !salesData.product_list) {
|
||||
setError('No hay datos de ventas para importar');
|
||||
return {
|
||||
success: false,
|
||||
recordsCreated: 0,
|
||||
message: 'Error: No hay datos de ventas para importar',
|
||||
};
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let recordsCreated = 0;
|
||||
|
||||
// Process actual sales data and create sales records
|
||||
if (salesData.raw_data && Array.isArray(salesData.raw_data)) {
|
||||
for (const salesRecord of salesData.raw_data) {
|
||||
try {
|
||||
// Map product name to inventory product ID
|
||||
const inventoryProductId = inventoryMapping[salesRecord.product_name];
|
||||
|
||||
if (inventoryProductId) {
|
||||
const salesRecordData = {
|
||||
date: salesRecord.date,
|
||||
product_name: salesRecord.product_name,
|
||||
inventory_product_id: inventoryProductId,
|
||||
quantity_sold: salesRecord.quantity,
|
||||
unit_price: salesRecord.unit_price,
|
||||
total_revenue: salesRecord.total_amount || (salesRecord.quantity * salesRecord.unit_price),
|
||||
channel: salesRecord.channel || 'tienda',
|
||||
customer_info: salesRecord.customer_info || {},
|
||||
notes: salesRecord.notes || '',
|
||||
};
|
||||
|
||||
await createSalesRecordMutation.mutateAsync({
|
||||
tenantId: currentTenant.id,
|
||||
salesData: salesRecordData,
|
||||
});
|
||||
|
||||
recordsCreated++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating sales record:', error);
|
||||
// Continue with next record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const importResult = {
|
||||
success: recordsCreated > 0,
|
||||
imported: recordsCreated > 0,
|
||||
records_created: recordsCreated,
|
||||
message: recordsCreated > 0
|
||||
? `Se importaron ${recordsCreated} registros de ventas exitosamente`
|
||||
: 'No se pudieron importar registros de ventas',
|
||||
};
|
||||
|
||||
// Update state
|
||||
setSalesImportResult(importResult);
|
||||
|
||||
// Update onboarding store
|
||||
setStepData('smart-inventory-setup', {
|
||||
salesImportResult: importResult,
|
||||
});
|
||||
|
||||
return {
|
||||
success: recordsCreated > 0,
|
||||
recordsCreated,
|
||||
message: importResult.message,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error importando datos de ventas';
|
||||
setError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
recordsCreated: 0,
|
||||
message: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [createSalesRecordMutation, currentTenant, setStepData]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setCreatedItems([]);
|
||||
setInventoryMapping({});
|
||||
setSalesImportResult(null);
|
||||
setIsInventoryConfigured(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
createdItems,
|
||||
inventoryMapping,
|
||||
salesImportResult,
|
||||
isInventoryConfigured,
|
||||
|
||||
// Actions
|
||||
createInventoryFromSuggestions,
|
||||
importSalesData,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Progress tracking service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { onboardingService } from '../../../../api/services/onboarding';
|
||||
import type { UserProgress } from '../../../../api/types/onboarding';
|
||||
|
||||
export const useProgressTracking = () => {
|
||||
const initializationAttempted = useRef(false);
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UserProgress | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [completionPercentage, setCompletionPercentage] = useState(0);
|
||||
const [currentBackendStep, setCurrentBackendStep] = useState<string | null>(null);
|
||||
|
||||
// Load initial progress from backend
|
||||
const loadProgress = useCallback(async (): Promise<UserProgress | null> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const progressData = await onboardingService.getUserProgress('');
|
||||
|
||||
// Update state
|
||||
setProgress(progressData);
|
||||
setIsInitialized(true);
|
||||
setIsCompleted(progressData?.fully_completed || false);
|
||||
setCompletionPercentage(progressData?.completion_percentage || 0);
|
||||
setCurrentBackendStep(progressData?.current_step || null);
|
||||
|
||||
console.log('📊 Progress loaded:', progressData?.current_step, '(', progressData?.completion_percentage, '%)');
|
||||
|
||||
return progressData;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error loading progress';
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mark a step as completed and save to backend
|
||||
const markStepCompleted = useCallback(async (
|
||||
stepId: string,
|
||||
data?: Record<string, any>
|
||||
): Promise<boolean> => {
|
||||
console.log(`🔄 Attempting to mark step "${stepId}" as completed with data:`, data);
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedProgress = await onboardingService.markStepAsCompleted(stepId, data);
|
||||
|
||||
// Update state
|
||||
setProgress(updatedProgress);
|
||||
setIsCompleted(updatedProgress?.fully_completed || false);
|
||||
setCompletionPercentage(updatedProgress?.completion_percentage || 0);
|
||||
setCurrentBackendStep(updatedProgress?.current_step || null);
|
||||
|
||||
console.log(`✅ Step "${stepId}" marked as completed in backend`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : `Error marking step "${stepId}" as completed`;
|
||||
setError(errorMessage);
|
||||
console.error(`❌ Error marking step "${stepId}" as completed:`, error);
|
||||
console.error(`❌ Attempted to send data:`, data);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the next step the user should work on
|
||||
const getNextStep = useCallback(async (): Promise<string> => {
|
||||
try {
|
||||
return await onboardingService.getNextStepId();
|
||||
} catch (error) {
|
||||
console.error('Error getting next step:', error);
|
||||
return 'setup';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the step and index where user should resume
|
||||
const getResumePoint = useCallback(async (): Promise<{ stepId: string; stepIndex: number }> => {
|
||||
try {
|
||||
return await onboardingService.getResumeStep();
|
||||
} catch (error) {
|
||||
console.error('Error getting resume point:', error);
|
||||
return { stepId: 'setup', stepIndex: 0 };
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Complete the entire onboarding process
|
||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await onboardingService.completeOnboarding();
|
||||
|
||||
if (result.success) {
|
||||
// Reload progress to get updated status
|
||||
await loadProgress();
|
||||
console.log('🎉 Onboarding completed successfully!');
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new Error('Failed to complete onboarding');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error completing onboarding';
|
||||
setError(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadProgress]);
|
||||
|
||||
// Check if user can access a specific step
|
||||
const canAccessStep = useCallback(async (stepId: string): Promise<boolean> => {
|
||||
try {
|
||||
const result = await onboardingService.canAccessStep(stepId);
|
||||
return result.can_access;
|
||||
} catch (error) {
|
||||
console.error(`Error checking access for step "${stepId}":`, error);
|
||||
return true; // Allow access on error
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-load progress on hook initialization - PREVENT multiple attempts
|
||||
useEffect(() => {
|
||||
if (!isInitialized && !initializationAttempted.current && !isLoading) {
|
||||
initializationAttempted.current = true;
|
||||
loadProgress();
|
||||
}
|
||||
}, [isInitialized, isLoading, loadProgress]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
progress,
|
||||
isInitialized,
|
||||
isCompleted,
|
||||
completionPercentage,
|
||||
currentBackendStep,
|
||||
|
||||
// Actions
|
||||
loadProgress,
|
||||
markStepCompleted,
|
||||
getNextStep,
|
||||
getResumePoint,
|
||||
completeOnboarding,
|
||||
canAccessStep,
|
||||
clearError,
|
||||
};
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
/**
|
||||
* Resume logic service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOnboardingStore } from '../core/store';
|
||||
import { useProgressTracking } from './useProgressTracking';
|
||||
|
||||
export const useResumeLogic = () => {
|
||||
const navigate = useNavigate();
|
||||
const progressTracking = useProgressTracking();
|
||||
const { setCurrentStep } = useOnboardingStore();
|
||||
const resumeAttempted = useRef(false);
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCheckingResume, setIsCheckingResume] = useState(false);
|
||||
const [resumePoint, setResumePoint] = useState<{ stepId: string; stepIndex: number } | null>(null);
|
||||
const [shouldResume, setShouldResume] = useState(false);
|
||||
|
||||
// Check if user should resume onboarding
|
||||
const checkForResume = useCallback(async (): Promise<boolean> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setIsCheckingResume(true);
|
||||
|
||||
try {
|
||||
// Load user's progress from backend
|
||||
await progressTracking.loadProgress();
|
||||
|
||||
if (!progressTracking.progress) {
|
||||
console.log('🔍 No progress found, starting from beginning');
|
||||
setIsCheckingResume(false);
|
||||
setShouldResume(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// If onboarding is already completed, don't resume
|
||||
if (progressTracking.isCompleted) {
|
||||
console.log('✅ Onboarding completed, redirecting to dashboard');
|
||||
navigate('/app/dashboard');
|
||||
setIsCheckingResume(false);
|
||||
setShouldResume(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the resume point from backend
|
||||
const resumePointData = await progressTracking.getResumePoint();
|
||||
|
||||
console.log('🎯 Resuming from step:', resumePointData.stepId);
|
||||
setIsCheckingResume(false);
|
||||
setResumePoint(resumePointData);
|
||||
setShouldResume(true);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error checking resume';
|
||||
setError(errorMessage);
|
||||
setIsCheckingResume(false);
|
||||
setShouldResume(false);
|
||||
console.error('❌ Resume check failed:', errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [progressTracking, navigate]);
|
||||
|
||||
// Resume the onboarding flow from the correct step
|
||||
const resumeFlow = useCallback((): boolean => {
|
||||
if (!resumePoint) {
|
||||
console.warn('⚠️ No resume point available');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stepIndex } = resumePoint;
|
||||
|
||||
// Navigate to the correct step in the flow
|
||||
setCurrentStep(stepIndex);
|
||||
|
||||
console.log('✅ Resumed onboarding at step', stepIndex);
|
||||
setShouldResume(false);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Error resuming flow:', error);
|
||||
return false;
|
||||
}
|
||||
}, [resumePoint, setCurrentStep]);
|
||||
|
||||
// Handle automatic resume on mount
|
||||
const handleAutoResume = useCallback(async () => {
|
||||
try {
|
||||
// Add a timeout to prevent hanging indefinitely
|
||||
const timeoutPromise = new Promise<boolean>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Resume check timeout')), 10000)
|
||||
);
|
||||
|
||||
const shouldResumeResult = await Promise.race([
|
||||
checkForResume(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
if (shouldResumeResult) {
|
||||
// Wait a bit for state to update, then resume
|
||||
setTimeout(() => {
|
||||
resumeFlow();
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Auto-resume failed:', error);
|
||||
// Reset the checking state in case of error
|
||||
setIsCheckingResume(false);
|
||||
setShouldResume(false);
|
||||
}
|
||||
}, [checkForResume, resumeFlow]);
|
||||
|
||||
// Auto-check for resume when the hook is first used - PREVENT multiple attempts
|
||||
useEffect(() => {
|
||||
if (progressTracking.isInitialized && !isCheckingResume && !resumeAttempted.current) {
|
||||
resumeAttempted.current = true;
|
||||
handleAutoResume();
|
||||
}
|
||||
}, [progressTracking.isInitialized, isCheckingResume, handleAutoResume]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
isCheckingResume,
|
||||
resumePoint,
|
||||
shouldResume,
|
||||
|
||||
// Actions
|
||||
checkForResume,
|
||||
resumeFlow,
|
||||
handleAutoResume,
|
||||
clearError,
|
||||
|
||||
// Progress tracking state for convenience
|
||||
progress: progressTracking.progress,
|
||||
isCompleted: progressTracking.isCompleted,
|
||||
completionPercentage: progressTracking.completionPercentage,
|
||||
};
|
||||
};
|
||||
@@ -1,317 +0,0 @@
|
||||
/**
|
||||
* Sales processing service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useClassifyProductsBatch, useValidateFileOnly } from '../../../../api';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { useOnboardingStore } from '../core/store';
|
||||
import type { ProgressCallback, ProductSuggestionResponse } from '../core/types';
|
||||
|
||||
export const useSalesProcessing = () => {
|
||||
const classifyProductsMutation = useClassifyProductsBatch();
|
||||
const currentTenant = useCurrentTenant();
|
||||
const { validateFile } = useValidateFileOnly();
|
||||
const { setStepData } = useOnboardingStore();
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<ProductSuggestionResponse[]>([]);
|
||||
const [stage, setStage] = useState<'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error'>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentMessage, setCurrentMessage] = useState('');
|
||||
const [validationResults, setValidationResults] = useState<any | null>(null);
|
||||
|
||||
const updateProgress = useCallback((
|
||||
progressValue: number,
|
||||
stageValue: 'idle' | 'validating' | 'validated' | 'analyzing' | 'completed' | 'error',
|
||||
message: string,
|
||||
onProgress?: ProgressCallback
|
||||
) => {
|
||||
setProgress(progressValue);
|
||||
setStage(stageValue);
|
||||
setCurrentMessage(message);
|
||||
onProgress?.(progressValue, stageValue, message);
|
||||
}, []);
|
||||
|
||||
const extractProductList = useCallback((validationResult: any): string[] => {
|
||||
// First try to use the direct product_list from backend response
|
||||
if (validationResult.product_list && Array.isArray(validationResult.product_list)) {
|
||||
return validationResult.product_list;
|
||||
}
|
||||
|
||||
// Fallback: Extract unique product names from sample records
|
||||
if (validationResult.sample_records && Array.isArray(validationResult.sample_records)) {
|
||||
const productSet = new Set<string>();
|
||||
validationResult.sample_records.forEach((record: any) => {
|
||||
if (record.product_name) {
|
||||
productSet.add(record.product_name);
|
||||
}
|
||||
});
|
||||
return Array.from(productSet);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, []);
|
||||
|
||||
const generateSuggestions = useCallback(async (productList: string[]): Promise<ProductSuggestionResponse[]> => {
|
||||
try {
|
||||
if (!currentTenant?.id) {
|
||||
console.error('❌ No tenant ID available for classification');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('🔄 Generating suggestions for', productList.length, 'products');
|
||||
|
||||
const requestPayload = {
|
||||
tenantId: currentTenant.id,
|
||||
batchData: {
|
||||
products: productList.map(name => ({
|
||||
product_name: name,
|
||||
description: ''
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
console.log('📡 Making API request to:', `/tenants/${currentTenant.id}/inventory/classify-products-batch`);
|
||||
|
||||
// Add timeout to the API call with shorter timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('API timeout: The inventory service may be overloaded. Please try again in a few moments.')), 15000); // 15 second timeout
|
||||
});
|
||||
|
||||
const response = await Promise.race([
|
||||
classifyProductsMutation.mutateAsync(requestPayload),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
console.log('✅ Generated', response?.length || 0, 'suggestions');
|
||||
|
||||
return response || [];
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof Error && error.message.includes('timeout');
|
||||
const isNetworkError = error instanceof Error && (error.message.includes('fetch') || error.message.includes('network'));
|
||||
|
||||
console.error('❌ Error generating suggestions:', {
|
||||
error: error,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
isTimeout,
|
||||
isNetworkError
|
||||
});
|
||||
|
||||
// Re-throw timeout/network errors so they can be handled properly by the UI
|
||||
if (isTimeout || isNetworkError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}, [classifyProductsMutation, currentTenant]);
|
||||
|
||||
const processFile = useCallback(async (
|
||||
file: File,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
validationResults?: any;
|
||||
suggestions?: ProductSuggestionResponse[];
|
||||
}> => {
|
||||
console.log('🚀 Processing file:', file.name);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Stage 1: Validate file structure
|
||||
updateProgress(10, 'validating', 'Iniciando validación del archivo...', onProgress);
|
||||
updateProgress(20, 'validating', 'Validando estructura del archivo...', onProgress);
|
||||
|
||||
if (!currentTenant?.id) {
|
||||
throw new Error('No se pudo obtener información del tenant');
|
||||
}
|
||||
|
||||
const result = await validateFile(
|
||||
currentTenant.id,
|
||||
file,
|
||||
{
|
||||
onProgress: (stage, progress, message) => {
|
||||
updateProgress(progress, stage as any, message, onProgress);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success || !result.validationResult) {
|
||||
throw new Error(result.error || 'Error en la validación del archivo');
|
||||
}
|
||||
|
||||
const validationResult = {
|
||||
...result.validationResult,
|
||||
product_list: extractProductList(result.validationResult),
|
||||
};
|
||||
|
||||
console.log('📊 File validated:', validationResult.product_list?.length, 'products found');
|
||||
|
||||
updateProgress(40, 'validating', 'Verificando integridad de datos...', onProgress);
|
||||
|
||||
if (!validationResult || !validationResult.product_list || validationResult.product_list.length === 0) {
|
||||
console.error('❌ No products found in file');
|
||||
throw new Error('No se encontraron productos válidos en el archivo');
|
||||
}
|
||||
|
||||
// Stage 2: File validation completed - WAIT FOR USER CONFIRMATION
|
||||
updateProgress(100, 'validated', 'Archivo validado correctamente. Esperando confirmación del usuario...', onProgress);
|
||||
|
||||
// Store validation results and wait for user action
|
||||
setValidationResults(validationResult);
|
||||
|
||||
console.log('✅ File validation completed:', validationResult.product_list?.length, 'products found');
|
||||
|
||||
// Update onboarding store - ONLY with validation results
|
||||
setStepData('smart-inventory-setup', {
|
||||
files: { salesData: file },
|
||||
processingStage: 'validated', // Changed from 'completed'
|
||||
processingResults: validationResult,
|
||||
// DON'T set suggestions here - they will be generated later
|
||||
});
|
||||
|
||||
console.log('📊 Updated onboarding store with suggestions');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
validationResults: validationResult,
|
||||
// No suggestions returned from processFile - they will be generated separately
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo';
|
||||
|
||||
setError(errorMessage);
|
||||
updateProgress(0, 'error', errorMessage, onProgress);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [updateProgress, currentTenant, validateFile, extractProductList, setStepData]);
|
||||
|
||||
const generateProductSuggestions = useCallback(async (
|
||||
productList: string[],
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
suggestions?: ProductSuggestionResponse[];
|
||||
error?: string;
|
||||
}> => {
|
||||
console.log('🚀 Generating product suggestions for', productList.length, 'products');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
updateProgress(10, 'analyzing', 'Iniciando análisis de productos...', onProgress);
|
||||
updateProgress(30, 'analyzing', 'Identificando productos únicos...', onProgress);
|
||||
updateProgress(50, 'analyzing', 'Generando sugerencias de IA...', onProgress);
|
||||
|
||||
let suggestions: ProductSuggestionResponse[] = [];
|
||||
let suggestionError: string | null = null;
|
||||
|
||||
try {
|
||||
updateProgress(70, 'analyzing', 'Consultando servicios de IA...', onProgress);
|
||||
suggestions = await generateSuggestions(productList);
|
||||
|
||||
console.log('🔍 Generated suggestions:', {
|
||||
suggestionsReceived: suggestions?.length || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error generando sugerencias';
|
||||
suggestionError = errorMessage;
|
||||
console.error('❌ Suggestions generation failed:', errorMessage);
|
||||
|
||||
// Create basic suggestions from product names as fallback
|
||||
updateProgress(80, 'analyzing', 'Preparando productos básicos...', onProgress);
|
||||
|
||||
suggestions = productList.map((productName, index) => ({
|
||||
suggestion_id: `manual-${index}`,
|
||||
original_name: productName,
|
||||
suggested_name: productName,
|
||||
product_type: 'ingredient',
|
||||
category: 'Sin categoría',
|
||||
unit_of_measure: 'units',
|
||||
confidence_score: 0.5,
|
||||
estimated_shelf_life_days: 30,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
notes: 'Clasificación manual - El servicio de IA no está disponible temporalmente'
|
||||
}));
|
||||
|
||||
console.log('🔧 Created fallback suggestions:', suggestions.length);
|
||||
}
|
||||
|
||||
updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress);
|
||||
updateProgress(100, 'completed', 'Sugerencias generadas correctamente', onProgress);
|
||||
|
||||
// Update state with suggestions
|
||||
setSuggestions(suggestions || []);
|
||||
|
||||
console.log('✅ Suggestions generation completed:', suggestions?.length || 0, 'suggestions');
|
||||
|
||||
// Update onboarding store with suggestions
|
||||
setStepData('smart-inventory-setup', (prevData) => ({
|
||||
...prevData,
|
||||
processingStage: 'completed',
|
||||
suggestions: suggestions || [],
|
||||
suggestionError: suggestionError,
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
suggestions: suggestions || [],
|
||||
error: suggestionError || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error generando sugerencias';
|
||||
|
||||
setError(errorMessage);
|
||||
updateProgress(0, 'error', errorMessage, onProgress);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [updateProgress, generateSuggestions, setStepData]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setSuggestions([]);
|
||||
setStage('idle');
|
||||
setProgress(0);
|
||||
setCurrentMessage('');
|
||||
setValidationResults(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
stage,
|
||||
progress,
|
||||
currentMessage,
|
||||
validationResults,
|
||||
suggestions,
|
||||
|
||||
// Actions
|
||||
processFile,
|
||||
generateProductSuggestions, // New separated function
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Tenant creation service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useRegisterBakery } from '../../../../api';
|
||||
import { useTenantStore } from '../../../../stores/tenant.store';
|
||||
import { useOnboardingStore } from '../core/store';
|
||||
import type { BakeryRegistration, TenantResponse } from '../../../../api';
|
||||
|
||||
export const useTenantCreation = () => {
|
||||
const registerBakeryMutation = useRegisterBakery();
|
||||
const { setCurrentTenant, loadUserTenants } = useTenantStore();
|
||||
const { setStepData } = useOnboardingStore();
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [tenantData, setTenantData] = useState<TenantResponse | null>(null);
|
||||
|
||||
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
||||
if (!bakeryData) {
|
||||
setError('Los datos de la panadería son requeridos');
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Call API to register bakery
|
||||
const tenantResponse: TenantResponse = await registerBakeryMutation.mutateAsync(bakeryData);
|
||||
|
||||
// Update tenant store
|
||||
setCurrentTenant(tenantResponse);
|
||||
|
||||
// Reload user tenants
|
||||
await loadUserTenants();
|
||||
|
||||
// Update state
|
||||
setTenantData(tenantResponse);
|
||||
setIsSuccess(true);
|
||||
|
||||
// Update onboarding data
|
||||
setStepData('setup', {
|
||||
bakery: {
|
||||
...bakeryData,
|
||||
tenantCreated: true,
|
||||
tenant_id: tenantResponse.id,
|
||||
} as any,
|
||||
});
|
||||
|
||||
console.log('✅ Tenant created successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error creating tenant';
|
||||
setError(errorMessage);
|
||||
setIsSuccess(false);
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [registerBakeryMutation, setCurrentTenant, loadUserTenants, setStepData]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setIsSuccess(false);
|
||||
setTenantData(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
isSuccess,
|
||||
tenantData,
|
||||
|
||||
// Actions
|
||||
createTenant,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* Training orchestration service - Simplified implementation
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
useCreateTrainingJob,
|
||||
useTrainingJobStatus,
|
||||
useTrainingWebSocket,
|
||||
} from '../../../../api';
|
||||
import { useCurrentTenant } from '../../../../stores';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useOnboardingStore } from '../core/store';
|
||||
import type { TrainingLog, TrainingMetrics } from '../core/types';
|
||||
import type { TrainingJobResponse } from '../../../../api';
|
||||
|
||||
export const useTrainingOrchestration = () => {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const createTrainingJobMutation = useCreateTrainingJob();
|
||||
const { setStepData, getAllStepData } = useOnboardingStore();
|
||||
|
||||
// Simple, direct state management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'validating' | 'training' | 'completed' | 'failed'>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(0);
|
||||
const [job, setJob] = useState<TrainingJobResponse | null>(null);
|
||||
const [logs, setLogs] = useState<TrainingLog[]>([]);
|
||||
const [metrics, setMetrics] = useState<TrainingMetrics | null>(null);
|
||||
|
||||
const addLog = useCallback((message: string, level: TrainingLog['level'] = 'info') => {
|
||||
const newLog: TrainingLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
level
|
||||
};
|
||||
|
||||
setLogs(prevLogs => [...prevLogs, newLog]);
|
||||
}, []);
|
||||
|
||||
// Get job status when we have a job ID
|
||||
const { data: jobStatus } = useTrainingJobStatus(
|
||||
currentTenant?.id || '',
|
||||
job?.job_id || '',
|
||||
{
|
||||
enabled: !!currentTenant?.id && !!job?.job_id && status === 'training',
|
||||
refetchInterval: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const { data: wsData } = useTrainingWebSocket(
|
||||
currentTenant?.id || '',
|
||||
job?.job_id || '',
|
||||
(user as any)?.token,
|
||||
{
|
||||
onProgress: (data: any) => {
|
||||
setProgress(data.progress?.percentage || progress);
|
||||
setCurrentStep(data.progress?.current_step || currentStep);
|
||||
setEstimatedTimeRemaining(data.progress?.estimated_time_remaining || estimatedTimeRemaining);
|
||||
addLog(
|
||||
`${data.progress?.current_step} - ${data.progress?.products_completed}/${data.progress?.products_total} productos procesados (${data.progress?.percentage}%)`,
|
||||
'info'
|
||||
);
|
||||
},
|
||||
onCompleted: (data: any) => {
|
||||
setStatus('completed');
|
||||
setProgress(100);
|
||||
setMetrics({
|
||||
accuracy: data.results.performance_metrics.accuracy,
|
||||
mape: data.results.performance_metrics.mape,
|
||||
mae: data.results.performance_metrics.mae,
|
||||
rmse: data.results.performance_metrics.rmse,
|
||||
r2_score: data.results.performance_metrics.r2_score || 0,
|
||||
});
|
||||
addLog('¡Entrenamiento ML completado exitosamente!', 'success');
|
||||
addLog(`${data.results.successful_trainings} modelos creados exitosamente`, 'success');
|
||||
addLog(`Duración total: ${Math.round(data.results.training_duration / 60)} minutos`, 'info');
|
||||
},
|
||||
onError: (data: any) => {
|
||||
setError(data.error);
|
||||
setStatus('failed');
|
||||
addLog(`Error en entrenamiento: ${data.error}`, 'error');
|
||||
},
|
||||
onStarted: (data: any) => {
|
||||
addLog(`Trabajo de entrenamiento iniciado: ${data.job_id}`, 'success');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Update status from polling when WebSocket is not available
|
||||
useEffect(() => {
|
||||
if (jobStatus && job?.job_id === jobStatus.job_id) {
|
||||
setStatus(jobStatus.status as any);
|
||||
setProgress(jobStatus.progress || progress);
|
||||
setCurrentStep(jobStatus.current_step || currentStep);
|
||||
setEstimatedTimeRemaining(jobStatus.estimated_time_remaining || estimatedTimeRemaining);
|
||||
}
|
||||
}, [jobStatus, job?.job_id, progress, currentStep, estimatedTimeRemaining]);
|
||||
|
||||
const validateTrainingData = useCallback(async (allStepData?: any): Promise<{
|
||||
isValid: boolean;
|
||||
missingItems: string[];
|
||||
}> => {
|
||||
const missingItems: string[] = [];
|
||||
const stepData = allStepData || getAllStepData();
|
||||
|
||||
|
||||
// Get data from the smart-inventory-setup step (where sales and inventory are handled)
|
||||
const smartInventoryData = stepData?.['smart-inventory-setup'];
|
||||
|
||||
// CRITICAL REQUIREMENT 1: Sales file must be uploaded
|
||||
if (!smartInventoryData?.files?.salesData) {
|
||||
missingItems.push('Archivo de datos de ventas históricos');
|
||||
}
|
||||
|
||||
// CRITICAL REQUIREMENT 2: Sales data must be processed and valid
|
||||
const hasProcessingResults = smartInventoryData?.processingResults &&
|
||||
smartInventoryData.processingResults.is_valid &&
|
||||
smartInventoryData.processingResults.total_records > 0;
|
||||
|
||||
if (!hasProcessingResults) {
|
||||
missingItems.push('Datos de ventas procesados y validados');
|
||||
}
|
||||
|
||||
// CRITICAL REQUIREMENT 3: Sales data must be imported to backend (MANDATORY for training)
|
||||
const hasImportResults = smartInventoryData?.salesImportResult &&
|
||||
(smartInventoryData.salesImportResult.records_created > 0 ||
|
||||
smartInventoryData.salesImportResult.success === true ||
|
||||
smartInventoryData.salesImportResult.imported === true);
|
||||
|
||||
if (!hasImportResults) {
|
||||
missingItems.push('Datos de ventas históricos importados al sistema');
|
||||
}
|
||||
|
||||
// CRITICAL REQUIREMENT 4: Products must be approved
|
||||
const hasApprovedProducts = smartInventoryData?.approvedProducts &&
|
||||
smartInventoryData.approvedProducts.length > 0 &&
|
||||
smartInventoryData.reviewCompleted;
|
||||
|
||||
if (!hasApprovedProducts) {
|
||||
missingItems.push('Productos revisados y aprobados');
|
||||
}
|
||||
|
||||
// CRITICAL REQUIREMENT 5: Inventory must be configured
|
||||
const hasInventoryConfig = smartInventoryData?.inventoryConfigured &&
|
||||
smartInventoryData?.inventoryItems &&
|
||||
smartInventoryData.inventoryItems.length > 0;
|
||||
|
||||
if (!hasInventoryConfig) {
|
||||
missingItems.push('Inventario configurado con productos');
|
||||
}
|
||||
|
||||
// CRITICAL REQUIREMENT 6: Minimum data volume for training
|
||||
if (smartInventoryData?.processingResults?.total_records &&
|
||||
smartInventoryData.processingResults.total_records < 10) {
|
||||
missingItems.push('Suficientes registros de ventas para entrenar (mínimo 10)');
|
||||
}
|
||||
|
||||
const isValid = missingItems.length === 0;
|
||||
|
||||
if (!isValid) {
|
||||
console.log('⚠️ Training validation failed:', missingItems.join(', '));
|
||||
}
|
||||
|
||||
return { isValid, missingItems };
|
||||
}, [getAllStepData]);
|
||||
|
||||
const startTraining = useCallback(async (options?: {
|
||||
products?: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<boolean> => {
|
||||
if (!currentTenant?.id) {
|
||||
setError('No se pudo obtener información del tenant');
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStatus('validating');
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
|
||||
addLog('Validando disponibilidad de datos...', 'info');
|
||||
|
||||
try {
|
||||
// Start training job
|
||||
addLog('Iniciando trabajo de entrenamiento ML...', 'info');
|
||||
const trainingJob = await createTrainingJobMutation.mutateAsync({
|
||||
tenantId: currentTenant.id,
|
||||
request: {
|
||||
products: options?.products,
|
||||
start_date: options?.startDate,
|
||||
end_date: options?.endDate,
|
||||
}
|
||||
});
|
||||
|
||||
// Update state
|
||||
setJob(trainingJob);
|
||||
setStatus('training');
|
||||
|
||||
// Update onboarding store
|
||||
setStepData('ml-training', {
|
||||
trainingStatus: 'training',
|
||||
trainingJob: trainingJob,
|
||||
});
|
||||
|
||||
addLog(`Trabajo de entrenamiento iniciado: ${trainingJob.job_id}`, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Error starting training';
|
||||
setError(errorMessage);
|
||||
setStatus('failed');
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTenant, createTrainingJobMutation, addLog, setStepData]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
setCurrentStep('');
|
||||
setEstimatedTimeRemaining(0);
|
||||
setJob(null);
|
||||
setLogs([]);
|
||||
setMetrics(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
status,
|
||||
progress,
|
||||
currentStep,
|
||||
estimatedTimeRemaining,
|
||||
job,
|
||||
logs,
|
||||
metrics,
|
||||
|
||||
// Actions
|
||||
startTraining,
|
||||
validateTrainingData,
|
||||
addLog,
|
||||
clearError,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Main onboarding hook - Clean, unified interface for components
|
||||
* This is the primary hook that all components should use
|
||||
*/
|
||||
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useCurrentTenant } from '../../../stores';
|
||||
import { useOnboardingStore } from './core/store';
|
||||
import { useOnboardingActions } from './core/actions';
|
||||
import { useTenantCreation } from './services/useTenantCreation';
|
||||
import { useSalesProcessing } from './services/useSalesProcessing';
|
||||
import { useInventorySetup } from './services/useInventorySetup';
|
||||
import { useTrainingOrchestration } from './services/useTrainingOrchestration';
|
||||
import { useProgressTracking } from './services/useProgressTracking';
|
||||
import { useResumeLogic } from './services/useResumeLogic';
|
||||
|
||||
export const useOnboarding = () => {
|
||||
const user = useAuthUser();
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Core store and actions
|
||||
const store = useOnboardingStore();
|
||||
const actions = useOnboardingActions();
|
||||
|
||||
// Service hooks for detailed state access
|
||||
const tenantCreation = useTenantCreation();
|
||||
const salesProcessing = useSalesProcessing();
|
||||
const inventorySetup = useInventorySetup();
|
||||
const trainingOrchestration = useTrainingOrchestration();
|
||||
const progressTracking = useProgressTracking();
|
||||
const resumeLogic = useResumeLogic();
|
||||
|
||||
return {
|
||||
// Core state from store
|
||||
currentStep: store.currentStep, // Return the index, not the step object
|
||||
steps: store.steps,
|
||||
data: store.data,
|
||||
progress: store.getProgress(),
|
||||
isLoading: store.isLoading,
|
||||
error: store.error,
|
||||
|
||||
// User context
|
||||
user,
|
||||
currentTenant,
|
||||
|
||||
// Step data helpers
|
||||
stepData: {
|
||||
setup: store.getStepData('setup'),
|
||||
'smart-inventory-setup': store.getStepData('smart-inventory-setup'),
|
||||
suppliers: store.getStepData('suppliers'),
|
||||
'ml-training': store.getStepData('ml-training'),
|
||||
completion: store.getStepData('completion'),
|
||||
},
|
||||
|
||||
// Service states (for components that need detailed service info)
|
||||
tenantCreation: {
|
||||
isLoading: tenantCreation.isLoading,
|
||||
isSuccess: tenantCreation.isSuccess,
|
||||
error: tenantCreation.error,
|
||||
tenantData: tenantCreation.tenantData,
|
||||
},
|
||||
|
||||
salesProcessing: {
|
||||
isLoading: salesProcessing.isLoading,
|
||||
error: salesProcessing.error,
|
||||
stage: salesProcessing.stage,
|
||||
progress: salesProcessing.progress,
|
||||
currentMessage: salesProcessing.currentMessage,
|
||||
validationResults: salesProcessing.validationResults,
|
||||
suggestions: Array.isArray(salesProcessing.suggestions) ? salesProcessing.suggestions : [],
|
||||
},
|
||||
|
||||
inventorySetup: {
|
||||
isLoading: inventorySetup.isLoading,
|
||||
error: inventorySetup.error,
|
||||
createdItems: inventorySetup.createdItems,
|
||||
inventoryMapping: inventorySetup.inventoryMapping,
|
||||
salesImportResult: inventorySetup.salesImportResult,
|
||||
isInventoryConfigured: inventorySetup.isInventoryConfigured,
|
||||
},
|
||||
|
||||
trainingOrchestration: {
|
||||
isLoading: trainingOrchestration.isLoading,
|
||||
error: trainingOrchestration.error,
|
||||
status: trainingOrchestration.status,
|
||||
progress: trainingOrchestration.progress,
|
||||
currentStep: trainingOrchestration.currentStep,
|
||||
estimatedTimeRemaining: trainingOrchestration.estimatedTimeRemaining,
|
||||
job: trainingOrchestration.job,
|
||||
logs: trainingOrchestration.logs,
|
||||
metrics: trainingOrchestration.metrics,
|
||||
},
|
||||
|
||||
progressTracking: {
|
||||
isLoading: progressTracking.isLoading,
|
||||
error: progressTracking.error,
|
||||
progress: progressTracking.progress,
|
||||
isCompleted: progressTracking.isCompleted,
|
||||
completionPercentage: progressTracking.completionPercentage,
|
||||
isInitialized: progressTracking.isInitialized,
|
||||
currentBackendStep: progressTracking.currentBackendStep,
|
||||
},
|
||||
|
||||
resumeLogic: {
|
||||
isCheckingResume: resumeLogic.isCheckingResume,
|
||||
resumePoint: resumeLogic.resumePoint,
|
||||
shouldResume: resumeLogic.shouldResume,
|
||||
progress: resumeLogic.progress,
|
||||
isCompleted: resumeLogic.isCompleted,
|
||||
completionPercentage: resumeLogic.completionPercentage,
|
||||
},
|
||||
|
||||
// Actions from the core actions hook
|
||||
nextStep: actions.nextStep,
|
||||
previousStep: actions.previousStep,
|
||||
goToStep: actions.goToStep,
|
||||
validateCurrentStep: actions.validateCurrentStep,
|
||||
|
||||
// Step data management
|
||||
updateStepData: store.setStepData,
|
||||
clearStepData: store.clearStepData,
|
||||
|
||||
// Step-specific actions
|
||||
createTenant: actions.createTenant,
|
||||
processSalesFile: actions.processSalesFile,
|
||||
generateProductSuggestions: actions.generateProductSuggestions, // New separated function
|
||||
createInventoryFromSuggestions: actions.createInventoryFromSuggestions,
|
||||
importSalesData: actions.importSalesData,
|
||||
startTraining: actions.startTraining,
|
||||
completeOnboarding: actions.completeOnboarding,
|
||||
|
||||
// Service-specific actions (for components that need direct service access)
|
||||
generateSuggestions: salesProcessing.generateSuggestions,
|
||||
addTrainingLog: trainingOrchestration.addLog,
|
||||
validateTrainingData: trainingOrchestration.validateTrainingData,
|
||||
|
||||
// Resume actions
|
||||
checkForSavedProgress: resumeLogic.checkForResume,
|
||||
resumeFromSavedProgress: resumeLogic.resumeFlow,
|
||||
|
||||
// Utility actions
|
||||
clearError: actions.clearError,
|
||||
reset: actions.reset,
|
||||
};
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Universal service hook factory - creates standardized service hooks
|
||||
* This eliminates all the duplicate patterns across service hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ServiceState, ServiceActions } from '../core/types';
|
||||
|
||||
export interface ServiceHookConfig<T> {
|
||||
initialState?: Partial<T>;
|
||||
onSuccess?: (data: any, state: T) => T;
|
||||
onError?: (error: string, state: T) => T;
|
||||
resetState?: () => T;
|
||||
}
|
||||
|
||||
export function createServiceHook<TState extends ServiceState, TActions extends ServiceActions = ServiceActions>(
|
||||
config: ServiceHookConfig<TState> = {}
|
||||
) {
|
||||
return function useService() {
|
||||
const defaultState: ServiceState = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isSuccess: false,
|
||||
};
|
||||
|
||||
const [state, setState] = useState<TState>({
|
||||
...defaultState,
|
||||
...config.initialState,
|
||||
} as TState);
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState(prev => ({ ...prev, isLoading: loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error,
|
||||
isLoading: false,
|
||||
isSuccess: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setSuccess = useCallback((data: any) => {
|
||||
setState(prev => {
|
||||
const newState = {
|
||||
...prev,
|
||||
data,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isSuccess: true
|
||||
};
|
||||
return config.onSuccess ? config.onSuccess(data, newState) : newState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (config.resetState) {
|
||||
setState(config.resetState());
|
||||
} else {
|
||||
setState({
|
||||
...defaultState,
|
||||
...config.initialState,
|
||||
} as TState);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const executeAsync = useCallback(async <T>(
|
||||
asyncFn: () => Promise<T>
|
||||
): Promise<{ success: boolean; data?: T; error?: string }> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await asyncFn();
|
||||
setSuccess(result);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}, [setLoading, setSuccess, setError]);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Core actions
|
||||
setLoading,
|
||||
setError,
|
||||
setSuccess,
|
||||
clearError,
|
||||
reset,
|
||||
|
||||
// Async execution helper
|
||||
executeAsync,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
|
||||
import { useOnboarding, useAutoResume } from '../../../hooks/business/onboarding';
|
||||
import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
|
||||
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
|
||||
|
||||
// Step Components
|
||||
import { BakerySetupStep } from '../../../components/domain/onboarding/steps/BakerySetupStep';
|
||||
import { SmartInventorySetupStep } from '../../../components/domain/onboarding/steps/SmartInventorySetupStep';
|
||||
import { SuppliersStep } from '../../../components/domain/onboarding/steps/SuppliersStep';
|
||||
import { MLTrainingStep } from '../../../components/domain/onboarding/steps/MLTrainingStep';
|
||||
import { CompletionStep } from '../../../components/domain/onboarding/steps/CompletionStep';
|
||||
|
||||
const OnboardingPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthUser();
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
|
||||
// Use auto-resume functionality
|
||||
const autoResume = useAutoResume();
|
||||
|
||||
// Use the onboarding business hook
|
||||
const {
|
||||
currentStep,
|
||||
steps,
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
nextStep,
|
||||
previousStep,
|
||||
goToStep,
|
||||
updateStepData,
|
||||
validateCurrentStep,
|
||||
createTenant,
|
||||
processSalesFile,
|
||||
createInventoryFromSuggestions,
|
||||
completeOnboarding,
|
||||
clearError,
|
||||
reset
|
||||
} = useOnboarding();
|
||||
|
||||
// Map steps to components
|
||||
const stepComponents: { [key: string]: React.ComponentType<any> } = {
|
||||
'setup': BakerySetupStep,
|
||||
'smart-inventory-setup': SmartInventorySetupStep,
|
||||
'suppliers': SuppliersStep,
|
||||
'ml-training': MLTrainingStep,
|
||||
'completion': CompletionStep
|
||||
};
|
||||
|
||||
// Convert hook steps to OnboardingWizard format
|
||||
const wizardSteps: OnboardingStep[] = steps.map(step => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
description: step.description,
|
||||
component: stepComponents[step.id],
|
||||
isRequired: step.isRequired,
|
||||
validation: step.validation
|
||||
}));
|
||||
|
||||
// Debug logging - only show if there's an error or important state change
|
||||
if (error || isLoading || autoResume.isCheckingResume) {
|
||||
console.log('OnboardingPage Status:', {
|
||||
stepsLength: steps.length,
|
||||
currentStep,
|
||||
isLoading,
|
||||
error,
|
||||
isCheckingResume: autoResume.isCheckingResume,
|
||||
});
|
||||
}
|
||||
|
||||
const handleStepChange = (stepIndex: number, stepData: any) => {
|
||||
const stepId = steps[stepIndex]?.id;
|
||||
if (stepId) {
|
||||
updateStepData(stepId, stepData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = async (): Promise<boolean> => {
|
||||
try {
|
||||
const success = await nextStep();
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('Error in handleNext:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = (): boolean => {
|
||||
return previousStep();
|
||||
};
|
||||
|
||||
const handleComplete = async (allData: any): Promise<void> => {
|
||||
try {
|
||||
await completeOnboarding();
|
||||
} catch (error) {
|
||||
console.error('Error in handleComplete:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Redirect if user is not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/auth/login');
|
||||
}
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
// Clear error when user navigates away or when component mounts
|
||||
useEffect(() => {
|
||||
// Clear any existing errors when the page loads
|
||||
if (error) {
|
||||
console.log('🧹 Clearing existing error on mount:', error);
|
||||
clearError();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (error) {
|
||||
clearError();
|
||||
}
|
||||
};
|
||||
}, [clearError]); // Include clearError in dependencies
|
||||
|
||||
// Show loading while processing or checking for saved progress
|
||||
// Add a safety timeout - if checking resume takes more than 10 seconds, proceed anyway
|
||||
const [loadingTimeoutReached, setLoadingTimeoutReached] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading || autoResume.isCheckingResume) {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn('⚠️ Loading timeout reached, proceeding with onboarding');
|
||||
setLoadingTimeoutReached(true);
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
} else {
|
||||
setLoadingTimeoutReached(false);
|
||||
}
|
||||
}, [isLoading, autoResume.isCheckingResume]);
|
||||
|
||||
if ((isLoading || autoResume.isCheckingResume) && !loadingTimeoutReached) {
|
||||
const message = autoResume.isCheckingResume
|
||||
? "Verificando progreso guardado..."
|
||||
: "Procesando...";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<div className="mt-4 text-lg text-gray-600">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state with better recovery options
|
||||
if (error && !loadingTimeoutReached) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-xl font-semibold text-red-600 mb-4">Error en Onboarding</h2>
|
||||
<p className="text-gray-600 mb-4">{error}</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('🔄 User clicked retry - clearing error');
|
||||
clearError();
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Continuar con el Onboarding
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('🔄 User clicked reset');
|
||||
reset();
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Reiniciar Desde el Principio
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Si el problema persiste, recarga la página
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Safety check: ensure we have valid steps and current step
|
||||
if (wizardSteps.length === 0) {
|
||||
console.error('❌ No wizard steps available, this should not happen');
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-red-600 mb-4">Error de Configuración</h2>
|
||||
<p className="text-gray-600 mb-4">No se pudieron cargar los pasos del onboarding.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Recargar Página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<OnboardingWizard
|
||||
steps={wizardSteps}
|
||||
currentStep={currentStep}
|
||||
data={data}
|
||||
onStepChange={handleStepChange}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
onComplete={handleComplete}
|
||||
onGoToStep={goToStep}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
25
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
25
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { OnboardingWizard } from '../../components/domain/onboarding';
|
||||
import { PublicLayout } from '../../components/layout';
|
||||
|
||||
const OnboardingPage: React.FC = () => {
|
||||
return (
|
||||
<PublicLayout
|
||||
variant="full-width"
|
||||
maxWidth="full"
|
||||
headerProps={{
|
||||
showThemeToggle: true,
|
||||
showAuthButtons: false,
|
||||
variant: "minimal"
|
||||
}}
|
||||
>
|
||||
<div className="min-h-screen bg-[var(--bg-primary)] py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<OnboardingWizard />
|
||||
</div>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
1
frontend/src/pages/onboarding/index.ts
Normal file
1
frontend/src/pages/onboarding/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as OnboardingPage } from './OnboardingPage';
|
||||
@@ -38,7 +38,7 @@ const TrafficPage = React.lazy(() => import('../pages/app/data/traffic/TrafficPa
|
||||
const EventsPage = React.lazy(() => import('../pages/app/data/events/EventsPage'));
|
||||
|
||||
// Onboarding pages
|
||||
const OnboardingPage = React.lazy(() => import('../pages/app/onboarding/OnboardingPage'));
|
||||
const OnboardingPage = React.lazy(() => import('../pages/onboarding/OnboardingPage'));
|
||||
|
||||
export const AppRouter: React.FC = () => {
|
||||
return (
|
||||
@@ -262,7 +262,7 @@ export const AppRouter: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Onboarding Route - New Complete Flow */}
|
||||
{/* Onboarding Route - Protected but without AppShell */}
|
||||
<Route
|
||||
path="/app/onboarding"
|
||||
element={
|
||||
|
||||
@@ -92,13 +92,27 @@ export const useTenantStore = create<TenantState>()(
|
||||
get().setCurrentTenant(tenants[0]);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to load user tenants');
|
||||
// No tenants found - this is fine for users who haven't completed onboarding
|
||||
set({
|
||||
availableTenants: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tenants',
|
||||
});
|
||||
// Handle 404 gracefully - user might not have created any tenants yet
|
||||
if (error && typeof error === 'object' && 'status' in error && error.status === 404) {
|
||||
set({
|
||||
availableTenants: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tenants',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user