Simplify the onboardinf flow components 2

This commit is contained in:
Urtzi Alfaro
2025-09-08 21:44:04 +02:00
parent 2e1e696cb5
commit c8b1a941f8
6 changed files with 726 additions and 269 deletions

View File

@@ -3,9 +3,11 @@
* Provides data fetching, caching, and state management for training operations
*/
import React from 'react';
import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { trainingService } from '../services/training';
import { ApiError } from '../client/apiClient';
import { useAuthStore } from '../../stores/auth.store';
import type {
TrainingJobRequest,
TrainingJobResponse,
@@ -240,89 +242,240 @@ export const useTrainingWebSocket = (
}
) => {
const queryClient = useQueryClient();
const authToken = useAuthStore((state) => state.token);
const [isConnected, setIsConnected] = React.useState(false);
const [connectionError, setConnectionError] = React.useState<string | null>(null);
return useQuery({
queryKey: ['training-websocket', tenantId, jobId],
queryFn: () => {
return new Promise((resolve, reject) => {
try {
const ws = trainingService.createWebSocketConnection(tenantId, jobId, token);
// Memoize options to prevent unnecessary effect re-runs
const memoizedOptions = React.useMemo(() => options, [
options?.onProgress,
options?.onCompleted,
options?.onError,
options?.onStarted,
options?.onCancelled
]);
ws.onopen = () => {
console.log('Training WebSocket connected');
};
React.useEffect(() => {
if (!tenantId || !jobId || !memoizedOptions) {
return;
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Update job status in cache
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.status || oldData?.status || 'running',
progress: message.progress?.percentage || oldData?.progress || 0,
message: message.message || oldData?.message || '',
current_step: message.progress?.current_step || oldData?.current_step,
estimated_time_remaining: message.progress?.estimated_time_remaining || oldData?.estimated_time_remaining,
})
);
let ws: WebSocket | null = null;
let reconnectTimer: NodeJS.Timeout | null = null;
let isManuallyDisconnected = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = 3;
// Call appropriate callback based on message type
switch (message.type) {
case 'progress':
options?.onProgress?.(message);
break;
case 'completed':
options?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
resolve(message);
break;
case 'error':
options?.onError?.(message);
reject(new Error(message.error));
break;
case 'started':
options?.onStarted?.(message);
break;
case 'cancelled':
options?.onCancelled?.(message);
resolve(message);
break;
const connect = () => {
try {
setConnectionError(null);
const effectiveToken = token || authToken;
console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, {
tenantId,
jobId,
hasToken: !!effectiveToken
});
ws = trainingService.createWebSocketConnection(tenantId, jobId, token || authToken || undefined);
ws.onopen = () => {
console.log('✅ Training WebSocket connected successfully');
setIsConnected(true);
reconnectAttempts = 0; // Reset on successful connection
// Request current status on connection
try {
ws?.send('get_status');
console.log('📤 Requested current training status');
} catch (e) {
console.warn('Failed to request status on connection:', e);
}
// Set up periodic ping to keep connection alive
const pingInterval = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) {
try {
ws?.send('ping');
console.log('💓 Sent ping to server');
} catch (e) {
console.warn('Failed to send ping:', e);
clearInterval(pingInterval);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
reject(error);
} else {
clearInterval(pingInterval);
}
};
}, 30000); // Ping every 30 seconds
// Store interval for cleanup
(ws as any).pingInterval = pingInterval;
};
ws.onerror = (error) => {
console.error('Training WebSocket error:', error);
reject(error);
};
ws.onmessage = (event) => {
try {
// Handle non-JSON messages (like pong responses)
if (typeof event.data === 'string' && event.data === 'pong') {
console.log('🏓 Pong received from server');
return;
}
ws.onclose = () => {
console.log('Training WebSocket disconnected');
};
const message = JSON.parse(event.data);
console.log('🔔 Training WebSocket message received:', message);
// Return cleanup function
return () => {
ws.close();
};
} catch (error) {
reject(error);
}
});
},
enabled: !!tenantId && !!jobId,
refetchOnWindowFocus: false,
retry: false,
staleTime: Infinity,
});
// Handle heartbeat messages
if (message.type === 'heartbeat') {
console.log('💓 Heartbeat received from server');
return; // Don't process heartbeats further
}
// Extract data from backend message structure
const eventData = message.data || {};
const progress = eventData.progress || 0;
const currentStep = eventData.current_step || eventData.step_name || '';
const statusMessage = eventData.message || eventData.status || '';
// Update job status in cache with backend structure
queryClient.setQueryData(
trainingKeys.jobs.status(tenantId, jobId),
(oldData: TrainingJobStatus | undefined) => ({
...oldData,
job_id: jobId,
status: message.type === 'completed' ? 'completed' :
message.type === 'failed' ? 'failed' :
message.type === 'started' ? 'running' :
oldData?.status || 'running',
progress: typeof progress === 'number' ? progress : oldData?.progress || 0,
message: statusMessage || oldData?.message || '',
current_step: currentStep || oldData?.current_step,
estimated_time_remaining: eventData.estimated_time_remaining || oldData?.estimated_time_remaining,
})
);
// Call appropriate callback based on message type (exact backend mapping)
switch (message.type) {
case 'started':
memoizedOptions?.onStarted?.(message);
break;
case 'progress':
memoizedOptions?.onProgress?.(message);
break;
case 'step_completed':
memoizedOptions?.onProgress?.(message); // Treat step completion as progress
break;
case 'completed':
console.log('✅ Training completed successfully');
memoizedOptions?.onCompleted?.(message);
// Invalidate models and statistics
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() });
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) });
isManuallyDisconnected = true; // Don't reconnect after completion
break;
case 'failed':
console.log('❌ Training failed');
memoizedOptions?.onError?.(message);
isManuallyDisconnected = true; // Don't reconnect after failure
break;
case 'cancelled':
console.log('🛑 Training cancelled');
memoizedOptions?.onCancelled?.(message);
isManuallyDisconnected = true; // Don't reconnect after cancellation
break;
case 'current_status':
console.log('📊 Received current training status');
// Treat current status as progress update if it has progress data
if (message.data) {
memoizedOptions?.onProgress?.(message);
}
break;
default:
console.log(`🔍 Received unknown message type: ${message.type}`);
break;
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
setConnectionError('Error parsing message from server');
}
};
ws.onerror = (error) => {
console.error('Training WebSocket error:', error);
setConnectionError('WebSocket connection error');
setIsConnected(false);
};
ws.onclose = (event) => {
console.log(`❌ Training WebSocket disconnected. Code: ${event.code}, Reason: "${event.reason}"`);
setIsConnected(false);
// Detailed logging for different close codes
switch (event.code) {
case 1000:
console.log('🔒 WebSocket closed normally');
break;
case 1006:
console.log('⚠️ WebSocket closed abnormally (1006) - likely server-side issue or network problem');
break;
case 1001:
console.log('🔄 WebSocket endpoint going away');
break;
case 1003:
console.log('❌ WebSocket unsupported data received');
break;
default:
console.log(`❓ WebSocket closed with code ${event.code}`);
}
// Try to reconnect if not manually disconnected and haven't exceeded max attempts
if (!isManuallyDisconnected && event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000); // Exponential backoff, max 10s
console.log(`🔄 Attempting to reconnect WebSocket in ${delay/1000}s... (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts})`);
reconnectTimer = setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
} else if (reconnectAttempts >= maxReconnectAttempts) {
console.log(`❌ Max reconnection attempts (${maxReconnectAttempts}) reached. Giving up.`);
setConnectionError(`Connection failed after ${maxReconnectAttempts} attempts. The training job may not exist or the server may be unavailable.`);
}
};
} catch (error) {
console.error('Error creating WebSocket connection:', error);
setConnectionError('Failed to create WebSocket connection');
}
};
// Delay initial connection to ensure training job is created
const initialConnectionTimer = setTimeout(() => {
console.log('🚀 Starting initial WebSocket connection...');
connect();
}, 2000); // 2-second delay to let the job initialize
// Cleanup function
return () => {
isManuallyDisconnected = true;
if (initialConnectionTimer) {
clearTimeout(initialConnectionTimer);
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close(1000, 'Component unmounted');
}
setIsConnected(false);
};
}, [tenantId, jobId, token, authToken, queryClient, memoizedOptions]);
return {
isConnected,
connectionError
};
};
// Utility Hooks

View File

@@ -13,18 +13,9 @@ export interface ImportValidationResponse {
total_records: number;
valid_records: number;
invalid_records: number;
errors: string[];
warnings: string[];
summary: {
status: string;
file_format: string;
file_size_bytes: number;
file_size_mb: number;
estimated_processing_time_seconds: number;
validation_timestamp: string;
detected_columns: string[];
suggestions: string[];
};
errors: Array<Record<string, any>>;
warnings: Array<Record<string, any>>;
summary: Record<string, any>;
unique_products: number;
product_list: string[];
message: string;

View File

@@ -6,17 +6,17 @@
export interface IngredientCreate {
name: string;
description?: string;
category: string;
category?: string;
unit_of_measure: string;
minimum_stock_level: number;
maximum_stock_level: number;
low_stock_threshold: number;
max_stock_level?: number;
reorder_point: number;
shelf_life_days?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
}
@@ -25,15 +25,15 @@ export interface IngredientUpdate {
description?: string;
category?: string;
unit_of_measure?: string;
minimum_stock_level?: number;
maximum_stock_level?: number;
low_stock_threshold?: number;
max_stock_level?: number;
reorder_point?: number;
shelf_life_days?: number;
requires_refrigeration?: boolean;
requires_freezing?: boolean;
is_seasonal?: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
}
@@ -44,15 +44,15 @@ export interface IngredientResponse {
description?: string;
category: string;
unit_of_measure: string;
minimum_stock_level: number;
maximum_stock_level: number;
low_stock_threshold: number;
max_stock_level: number;
reorder_point: number;
shelf_life_days?: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
supplier_id?: string;
cost_per_unit?: number;
average_cost?: number;
notes?: string;
current_stock_level: number;
available_stock: number;

View File

@@ -1,8 +1,9 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../../ui/Button';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { useAuth } from '../../../contexts/AuthContext';
import { useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { useUserProgress, useMarkStepCompleted } from '../../../api/hooks/onboarding';
import { useTenantActions } from '../../../stores/tenant.store';
import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import {
@@ -27,11 +28,13 @@ interface StepProps {
isLastStep: boolean;
}
// Steps must match backend ONBOARDING_STEPS exactly
// Note: "user_registered" is auto-completed and not shown in UI
const STEPS: StepConfig[] = [
{
id: 'setup',
title: 'Registrar Panadería',
description: 'Configura la información de tu panadería',
description: 'Configura la información básica de tu panadería',
component: RegisterTenantStep,
},
{
@@ -43,37 +46,136 @@ const STEPS: StepConfig[] = [
{
id: 'ml-training',
title: 'Entrenamiento IA',
description: 'Entrena tu modelo de inteligencia artificial',
description: 'Entrena tu modelo de inteligencia artificial personalizado',
component: MLTrainingStep,
},
{
id: 'completion',
title: 'Configuración Completa',
description: 'Bienvenido a tu sistema de gestión de panadería',
description: '¡Bienvenido a tu sistema de gestión inteligente!',
component: CompletionStep,
},
];
export const OnboardingWizard: React.FC = () => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(false);
const navigate = useNavigate();
const { user } = useAuth();
// Initialize tenant data for authenticated users
useTenantInitializer();
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions();
// Auto-complete user_registered step if needed (runs first)
useEffect(() => {
if (userProgress && user?.id) {
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('🔄 Auto-completing user_registered step for new user...');
markStepCompleted.mutate({
userId: user.id,
stepName: 'user_registered',
data: {
auto_completed: true,
completed_at: new Date().toISOString(),
source: 'onboarding_wizard_auto_completion'
}
}, {
onSuccess: () => {
console.log('✅ user_registered step auto-completed successfully');
// The query will automatically refetch and update userProgress
},
onError: (error) => {
console.error('❌ Failed to auto-complete user_registered step:', error);
}
});
}
}
}, [userProgress, user?.id, markStepCompleted]);
// Initialize step index based on backend progress with validation
useEffect(() => {
if (userProgress && !isInitialized) {
console.log('🔄 Initializing onboarding progress:', userProgress);
// Check if user_registered step is completed
const userRegisteredStep = userProgress.steps.find(s => s.step_name === 'user_registered');
if (!userRegisteredStep?.completed) {
console.log('⏳ Waiting for user_registered step to be auto-completed...');
return; // Wait for auto-completion to finish
}
// Find the current step index based on backend progress
const currentStepFromBackend = userProgress.current_step;
let stepIndex = STEPS.findIndex(step => step.id === currentStepFromBackend);
console.log(`🎯 Backend current step: "${currentStepFromBackend}", found at index: ${stepIndex}`);
// If current step is not found (e.g., suppliers step), find the next incomplete step
if (stepIndex === -1) {
console.log('🔍 Current step not found in UI steps, finding first incomplete step...');
// Find the first incomplete step that user can access
for (let i = 0; i < STEPS.length; i++) {
const step = STEPS[i];
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
if (!stepProgress?.completed) {
stepIndex = i;
console.log(`📍 Found first incomplete step: "${step.id}" at index ${i}`);
break;
}
}
// If all visible steps are completed, go to last step
if (stepIndex === -1) {
stepIndex = STEPS.length - 1;
console.log('✅ All steps completed, going to last step');
}
}
// Ensure user can't skip ahead - find the first incomplete step
const firstIncompleteStepIndex = STEPS.findIndex(step => {
const stepProgress = userProgress.steps.find(s => s.step_name === step.id);
return !stepProgress?.completed;
});
if (firstIncompleteStepIndex !== -1 && stepIndex > firstIncompleteStepIndex) {
console.log(`🚫 User trying to skip ahead. Redirecting to first incomplete step at index ${firstIncompleteStepIndex}`);
stepIndex = firstIncompleteStepIndex;
}
console.log(`🎯 Final step index: ${stepIndex} ("${STEPS[stepIndex]?.id}")`);
if (stepIndex !== currentStepIndex) {
setCurrentStepIndex(stepIndex);
}
setIsInitialized(true);
}
}, [userProgress, isInitialized, currentStepIndex]);
const currentStep = STEPS[currentStepIndex];
const handlePrevious = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
};
const handleStepComplete = async (data?: any) => {
if (!user?.id) {
console.error('User ID not available');
return;
}
console.log(`🎯 Completing step: "${currentStep.id}" with data:`, data);
try {
// Special handling for setup step - set the created tenant in tenant store
if (currentStep.id === 'setup' && data?.tenant) {
@@ -81,11 +183,14 @@ export const OnboardingWizard: React.FC = () => {
}
// Mark step as completed in backend
console.log(`📤 Sending API request to complete step: "${currentStep.id}"`);
await markStepCompleted.mutateAsync({
userId: user?.id || '',
userId: user.id,
stepName: currentStep.id,
data
});
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
if (currentStep.id === 'completion') {
navigate('/app');
@@ -95,91 +200,235 @@ export const OnboardingWizard: React.FC = () => {
setCurrentStepIndex(currentStepIndex + 1);
}
}
} catch (error) {
console.error('Error marking step as completed:', error);
} catch (error: any) {
console.error(`❌ Error completing step "${currentStep.id}":`, error);
// Extract detailed error information
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
const statusCode = error?.response?.status;
console.error(`📊 Error details: Status ${statusCode}, Message: ${errorMessage}`);
// Check if it's a dependency error
if (errorMessage.includes('dependencies not met')) {
console.error('🚫 Dependencies not met for step:', currentStep.id);
// Check what dependencies are missing
if (userProgress) {
console.log('📋 Current progress:', userProgress);
console.log('📋 Completed steps:', userProgress.steps.filter(s => s.completed).map(s => s.step_name));
}
}
// Don't advance automatically on error - user should see the issue
alert(`Error al completar paso "${currentStep.title}": ${errorMessage}`);
}
};
// Show loading state while initializing progress
if (isLoadingProgress || !isInitialized) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="flex items-center justify-center space-x-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)] text-sm sm:text-base">Cargando tu progreso...</p>
</div>
</CardBody>
</Card>
</div>
);
}
// Show error state if progress fails to load
if (progressError) {
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6">
<Card padding="lg" shadow="lg">
<CardBody>
<div className="text-center space-y-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 mx-auto bg-[var(--color-error)]/10 rounded-full flex items-center justify-center">
<svg className="w-7 h-7 sm:w-8 sm:h-8 text-[var(--color-error)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h2 className="text-base sm:text-lg font-semibold text-[var(--text-primary)] mb-2">
Error al cargar progreso
</h2>
<p className="text-sm sm:text-base text-[var(--text-secondary)] mb-4 px-2">
No pudimos cargar tu progreso de configuración. Puedes continuar desde el inicio.
</p>
<Button
onClick={() => setIsInitialized(true)}
variant="primary"
size="lg"
>
Continuar
</Button>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
const StepComponent = currentStep.component;
const progressPercentage = userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
return (
<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>
<span className="text-sm text-[var(--text-secondary)]">
Paso {currentStepIndex + 1} de {STEPS.length}
</span>
<div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
{/* Enhanced Progress Header */}
<Card shadow="sm" padding="lg">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
<div className="text-center sm:text-left">
<h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
Bienvenido a Bakery IA
</h1>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
Configura tu sistema de gestión inteligente paso a paso
</p>
</div>
<div className="text-center sm:text-right">
<div className="text-sm text-[var(--text-secondary)]">
Paso {currentStepIndex + 1} de {STEPS.length}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{Math.round(progressPercentage)}% completado
</div>
</div>
</div>
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2">
{/* Progress Bar */}
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-2 sm:h-3 mb-4">
<div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
style={{ width: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }}
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary)]/80 h-2 sm:h-3 rounded-full transition-all duration-500 ease-out"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<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)]'
}`}
>
<div className={`text-xs font-medium mb-1`}>
{step.title}
</div>
<div className="text-xs opacity-75">
{step.description}
</div>
</div>
))}
{/* Mobile Step Indicators - Horizontal scroll on small screens */}
<div className="sm:hidden">
<div className="flex space-x-4 overflow-x-auto pb-2 px-1">
{STEPS.map((step, index) => {
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-shrink-0 text-center min-w-[80px] ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-1">
{isCompleted ? (
<div className="w-8 h-8 bg-[var(--color-success)] rounded-full flex items-center justify-center shadow-sm">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-8 h-8 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold shadow-sm ring-2 ring-[var(--color-primary)]/20">
{index + 1}
</div>
) : (
<div className="w-8 h-8 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs font-medium leading-tight">
{step.title}
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Desktop Step Indicators */}
<div className="hidden sm:flex sm:justify-between">
{STEPS.map((step, index) => {
const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
const isCurrent = index === currentStepIndex;
return (
<div
key={step.id}
className={`flex-1 text-center px-2 ${
isCompleted
? 'text-[var(--color-success)]'
: isCurrent
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]'
}`}
>
<div className="flex items-center justify-center mb-2">
{isCompleted ? (
<div className="w-7 h-7 bg-[var(--color-success)] rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : isCurrent ? (
<div className="w-7 h-7 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-sm font-bold">
{index + 1}
</div>
) : (
<div className="w-7 h-7 bg-[var(--bg-tertiary)] rounded-full flex items-center justify-center text-[var(--text-tertiary)] text-sm">
{index + 1}
</div>
)}
</div>
<div className="text-xs sm:text-sm font-medium mb-1">
{step.title}
</div>
<div className="text-xs opacity-75">
{step.description}
</div>
</div>
);
})}
</div>
</Card>
{/* Step Content */}
<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>
<Card shadow="lg" padding="none">
<CardHeader padding="lg" divider>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<div className="w-5 h-5 sm:w-6 sm:h-6 bg-[var(--color-primary)] rounded-full flex items-center justify-center text-white text-xs font-bold">
{currentStepIndex + 1}
</div>
</div>
<div className="flex-1">
<h2 className="text-lg sm:text-xl font-semibold text-[var(--text-primary)]">
{currentStep.title}
</h2>
<p className="text-[var(--text-secondary)] text-xs sm:text-sm">
{currentStep.description}
</p>
</div>
</div>
</CardHeader>
<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>
<CardBody padding="lg">
<StepComponent
onNext={() => {}} // No-op - steps must use onComplete instead
onPrevious={() => {}} // No-op - users cannot go back once they've moved forward
onComplete={handleStepComplete}
isFirstStep={currentStepIndex === 0}
isLastStep={currentStepIndex === STEPS.length - 1}
/>
</CardBody>
</Card>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useCallback } from 'react';
import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training';
@@ -32,50 +32,59 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
const currentTenant = useCurrentTenant();
const createTrainingJob = useCreateTrainingJob();
// WebSocket for real-time training progress
const trainingWebSocket = useTrainingWebSocket(
// Memoized WebSocket callbacks to prevent reconnections
const handleProgress = useCallback((data: any) => {
setTrainingProgress({
stage: 'training',
progress: data.data?.progress || 0,
message: data.data?.message || 'Entrenando modelo...',
currentStep: data.data?.current_step,
estimatedTimeRemaining: data.data?.estimated_time_remaining
});
}, []);
const handleCompleted = useCallback((_data: any) => {
setTrainingProgress({
stage: 'completed',
progress: 100,
message: 'Entrenamiento completado exitosamente'
});
setIsTraining(false);
setTimeout(() => {
onComplete({
jobId: jobId,
success: true,
message: 'Modelo entrenado correctamente'
});
}, 2000);
}, [onComplete, jobId]);
const handleError = useCallback((data: any) => {
setError(data.data?.error || data.error || 'Error durante el entrenamiento');
setIsTraining(false);
setTrainingProgress(null);
}, []);
const handleStarted = useCallback((_data: any) => {
setTrainingProgress({
stage: 'starting',
progress: 5,
message: 'Iniciando entrenamiento del modelo...'
});
}, []);
// WebSocket for real-time training progress - only connect when we have a jobId
const { isConnected, connectionError } = 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...'
});
}
}
jobId ? {
onProgress: handleProgress,
onCompleted: handleCompleted,
onError: handleError,
onStarted: handleStarted
} : undefined
);
const handleStartTraining = async () => {
@@ -201,9 +210,16 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
<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 className="flex items-center gap-2">
{jobId && (
<span className={`text-xs ${isConnected ? 'text-green-500' : 'text-red-500'}`}>
{isConnected ? '🟢 Conectado' : '🔴 Desconectado'}
</span>
)}
{trainingProgress.estimatedTimeRemaining && (
<span>Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)}</span>
)}
</div>
</div>
</div>
)}
@@ -224,9 +240,9 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
</ul>
</div>
{error && (
{(error || connectionError) && (
<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>
<p className="text-[var(--color-error)]">{error || connectionError}</p>
</div>
)}

View File

@@ -24,18 +24,28 @@ interface ProgressState {
interface InventoryItem {
suggestion_id: string;
original_name: string;
suggested_name: string;
product_type: string;
category: string;
unit_of_measure: string;
selected: boolean;
stock_quantity: number;
expiration_days: number;
cost_per_unit: number;
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;
};
// UI-specific fields
selected: boolean;
stock_quantity: number;
cost_per_unit: number;
}
export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
@@ -131,15 +141,9 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
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
}
// Extract product data from validation result - use the exact backend structure
const products = validationResult.product_list?.map((productName: string) => ({
product_name: productName
})) || [];
if (products.length === 0) {
@@ -158,7 +162,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
setProgressState({ stage: 'preparing', progress: 75, message: 'Preparando sugerencias de inventario...' });
// Convert API response to InventoryItem format
// Convert API response to InventoryItem format - use exact backend structure plus UI fields
const items: InventoryItem[] = suggestions.map(suggestion => {
// Calculate default stock quantity based on sales data
const defaultStock = Math.max(
@@ -172,19 +176,25 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
3.0;
return {
// Exact backend fields
suggestion_id: suggestion.suggestion_id,
original_name: suggestion.original_name,
suggested_name: suggestion.suggested_name,
product_type: suggestion.product_type,
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,
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
notes: suggestion.notes
suggested_supplier: suggestion.suggested_supplier,
notes: suggestion.notes,
sales_data: suggestion.sales_data,
// UI-specific fields
selected: suggestion.confidence_score > 0.7, // Auto-select high confidence items
stock_quantity: defaultStock,
cost_per_unit: estimatedCost
};
});
@@ -241,18 +251,31 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
const createdIngredients = [];
for (const item of selectedItems) {
// Ensure reorder_point > minimum_stock_level as required by backend validation
const minimumStock = Math.max(1, Math.ceil(item.stock_quantity * 0.2));
const calculatedReorderPoint = Math.ceil(item.stock_quantity * 0.3);
const reorderPoint = Math.max(minimumStock + 2, calculatedReorderPoint, minimumStock + 1);
console.log(`📊 Inventory validation for "${item.suggested_name}":`, {
stockQuantity: item.stock_quantity,
minimumStock,
calculatedReorderPoint,
finalReorderPoint: reorderPoint,
isValid: reorderPoint > minimumStock
});
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,
low_stock_threshold: minimumStock,
max_stock_level: item.stock_quantity * 2,
reorder_point: reorderPoint,
shelf_life_days: item.estimated_shelf_life_days || 30,
requires_refrigeration: item.requires_refrigeration,
requires_freezing: item.requires_freezing,
is_seasonal: item.is_seasonal,
cost_per_unit: item.cost_per_unit,
average_cost: item.cost_per_unit,
notes: item.notes || `Creado durante onboarding - Confianza: ${Math.round(item.confidence_score * 100)}%`
};
@@ -338,12 +361,12 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
{/* Summary */}
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<p className="font-medium">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-3 sm:space-y-0">
<div className="text-center sm:text-left">
<p className="font-medium text-sm sm:text-base">
{selectedCount} de {inventoryItems.length} artículos seleccionados
</p>
<p className="text-sm text-[var(--text-secondary)]">
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">
Los artículos con alta confianza están preseleccionados
</p>
</div>
@@ -351,6 +374,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
variant="outline"
size="sm"
onClick={handleSelectAll}
className="w-full sm:w-auto"
>
{allSelected ? 'Deseleccionar Todos' : 'Seleccionar Todos'}
</Button>
@@ -361,7 +385,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
<div className="space-y-4 max-h-96 overflow-y-auto">
{inventoryItems.map((item) => (
<div
key={item.id}
key={item.suggestion_id}
className={`border rounded-lg p-4 transition-colors ${
item.selected
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
@@ -373,7 +397,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
<input
type="checkbox"
checked={item.selected}
onChange={() => handleToggleSelection(item.id)}
onChange={() => handleToggleSelection(item.suggestion_id)}
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
/>
</div>
@@ -381,7 +405,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
<div className="flex-1 space-y-3">
<div>
<h3 className="font-medium text-[var(--text-primary)]">
{item.name}
{item.suggested_name}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{item.category} Unidad: {item.unit_of_measure}
@@ -395,18 +419,28 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
Requiere refrigeración
</span>
)}
{item.requires_freezing && (
<span className="text-xs bg-cyan-100 text-cyan-800 px-2 py-1 rounded">
Requiere congelación
</span>
)}
{item.is_seasonal && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
Producto estacional
</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)]">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm: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,
item.suggestion_id,
'stock_quantity',
Number(e.target.value)
)}
@@ -419,7 +453,7 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
step="0.01"
value={item.cost_per_unit.toString()}
onChange={(e) => handleUpdateItem(
item.id,
item.suggestion_id,
'cost_per_unit',
Number(e.target.value)
)}
@@ -429,13 +463,14 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
label="Días de Caducidad"
type="number"
min="1"
value={item.expiration_days.toString()}
value={(item.estimated_shelf_life_days || 30).toString()}
onChange={(e) => handleUpdateItem(
item.id,
'expiration_days',
item.suggestion_id,
'estimated_shelf_life_days',
Number(e.target.value)
)}
size="sm"
className="sm:col-span-2 lg:col-span-1"
/>
</div>
)}
@@ -452,10 +487,11 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
)}
{/* Actions */}
<div className="flex justify-between">
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-0">
<Button
variant="outline"
onClick={() => setShowInventoryStep(false)}
className="order-2 sm:order-1 w-full sm:w-auto"
>
Volver
</Button>
@@ -466,8 +502,14 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
loadingText="Creando Inventario..."
size="lg"
disabled={selectedCount === 0}
className="order-1 sm:order-2 w-full sm:w-auto"
>
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario
<span className="hidden sm:inline">
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario
</span>
<span className="sm:hidden">
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''}
</span>
</Button>
</div>
</div>
@@ -574,7 +616,9 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
<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>
<li key={index} className="text-[var(--color-warning)]">
{typeof warning === 'string' ? warning : JSON.stringify(warning)}
</li>
))}
</ul>
</div>
@@ -591,23 +635,26 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
)}
{/* Actions */}
<div className="flex justify-between">
<div className="flex flex-col sm:flex-row justify-between gap-3 sm:gap-0">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
className="order-2 sm:order-1 w-full sm:w-auto"
>
Previous
Anterior
</Button>
<div className="space-x-3">
<div className="flex flex-col sm:flex-row gap-3 order-1 sm:order-2">
{selectedFile && !validationResult && (
<Button
onClick={handleValidateFile}
isLoading={isValidating}
loadingText="Validating..."
loadingText="Validando..."
size="lg"
className="w-full sm:w-auto"
>
Validate File
Validar Archivo
</Button>
)}
@@ -615,8 +662,9 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
<Button
onClick={handleContinue}
size="lg"
className="w-full sm:w-auto"
>
Continue with This Data
Continuar con estos Datos
</Button>
)}
</div>