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 * 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 { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { trainingService } from '../services/training'; import { trainingService } from '../services/training';
import { ApiError } from '../client/apiClient'; import { ApiError } from '../client/apiClient';
import { useAuthStore } from '../../stores/auth.store';
import type { import type {
TrainingJobRequest, TrainingJobRequest,
TrainingJobResponse, TrainingJobResponse,
@@ -240,89 +242,240 @@ export const useTrainingWebSocket = (
} }
) => { ) => {
const queryClient = useQueryClient(); 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({ // Memoize options to prevent unnecessary effect re-runs
queryKey: ['training-websocket', tenantId, jobId], const memoizedOptions = React.useMemo(() => options, [
queryFn: () => { options?.onProgress,
return new Promise((resolve, reject) => { options?.onCompleted,
try { options?.onError,
const ws = trainingService.createWebSocketConnection(tenantId, jobId, token); options?.onStarted,
options?.onCancelled
]);
ws.onopen = () => { React.useEffect(() => {
console.log('Training WebSocket connected'); if (!tenantId || !jobId || !memoizedOptions) {
}; return;
}
ws.onmessage = (event) => { let ws: WebSocket | null = null;
try { let reconnectTimer: NodeJS.Timeout | null = null;
const message = JSON.parse(event.data); let isManuallyDisconnected = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = 3;
// Update job status in cache const connect = () => {
queryClient.setQueryData( try {
trainingKeys.jobs.status(tenantId, jobId), setConnectionError(null);
(oldData: TrainingJobStatus | undefined) => ({ const effectiveToken = token || authToken;
...oldData, console.log(`🔄 Attempting WebSocket connection (attempt ${reconnectAttempts + 1}/${maxReconnectAttempts + 1}):`, {
job_id: jobId, tenantId,
status: message.status || oldData?.status || 'running', jobId,
progress: message.progress?.percentage || oldData?.progress || 0, hasToken: !!effectiveToken
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,
})
);
// Call appropriate callback based on message type ws = trainingService.createWebSocketConnection(tenantId, jobId, token || authToken || undefined);
switch (message.type) {
case 'progress': ws.onopen = () => {
options?.onProgress?.(message); console.log('✅ Training WebSocket connected successfully');
break; setIsConnected(true);
case 'completed': reconnectAttempts = 0; // Reset on successful connection
options?.onCompleted?.(message);
// Invalidate models and statistics // Request current status on connection
queryClient.invalidateQueries({ queryKey: trainingKeys.models.all() }); try {
queryClient.invalidateQueries({ queryKey: trainingKeys.statistics(tenantId) }); ws?.send('get_status');
resolve(message); console.log('📤 Requested current training status');
break; } catch (e) {
case 'error': console.warn('Failed to request status on connection:', e);
options?.onError?.(message); }
reject(new Error(message.error));
break; // Set up periodic ping to keep connection alive
case 'started': const pingInterval = setInterval(() => {
options?.onStarted?.(message); if (ws?.readyState === WebSocket.OPEN && !isManuallyDisconnected) {
break; try {
case 'cancelled': ws?.send('ping');
options?.onCancelled?.(message); console.log('💓 Sent ping to server');
resolve(message); } catch (e) {
break; console.warn('Failed to send ping:', e);
clearInterval(pingInterval);
} }
} catch (error) { } else {
console.error('Error parsing WebSocket message:', error); clearInterval(pingInterval);
reject(error);
} }
}; }, 30000); // Ping every 30 seconds
ws.onerror = (error) => { // Store interval for cleanup
console.error('Training WebSocket error:', error); (ws as any).pingInterval = pingInterval;
reject(error); };
};
ws.onclose = () => { ws.onmessage = (event) => {
console.log('Training WebSocket disconnected'); try {
}; // Handle non-JSON messages (like pong responses)
if (typeof event.data === 'string' && event.data === 'pong') {
console.log('🏓 Pong received from server');
return;
}
// Return cleanup function const message = JSON.parse(event.data);
return () => {
ws.close(); console.log('🔔 Training WebSocket message received:', message);
};
} catch (error) { // Handle heartbeat messages
reject(error); if (message.type === 'heartbeat') {
} console.log('💓 Heartbeat received from server');
}); return; // Don't process heartbeats further
}, }
enabled: !!tenantId && !!jobId,
refetchOnWindowFocus: false, // Extract data from backend message structure
retry: false, const eventData = message.data || {};
staleTime: Infinity, 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 // Utility Hooks

View File

@@ -13,18 +13,9 @@ export interface ImportValidationResponse {
total_records: number; total_records: number;
valid_records: number; valid_records: number;
invalid_records: number; invalid_records: number;
errors: string[]; errors: Array<Record<string, any>>;
warnings: string[]; warnings: Array<Record<string, any>>;
summary: { summary: Record<string, any>;
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[];
};
unique_products: number; unique_products: number;
product_list: string[]; product_list: string[];
message: string; message: string;

View File

@@ -6,17 +6,17 @@
export interface IngredientCreate { export interface IngredientCreate {
name: string; name: string;
description?: string; description?: string;
category: string; category?: string;
unit_of_measure: string; unit_of_measure: string;
minimum_stock_level: number; low_stock_threshold: number;
maximum_stock_level: number; max_stock_level?: number;
reorder_point: number; reorder_point: number;
shelf_life_days?: number; shelf_life_days?: number;
requires_refrigeration?: boolean; requires_refrigeration?: boolean;
requires_freezing?: boolean; requires_freezing?: boolean;
is_seasonal?: boolean; is_seasonal?: boolean;
supplier_id?: string; supplier_id?: string;
cost_per_unit?: number; average_cost?: number;
notes?: string; notes?: string;
} }
@@ -25,15 +25,15 @@ export interface IngredientUpdate {
description?: string; description?: string;
category?: string; category?: string;
unit_of_measure?: string; unit_of_measure?: string;
minimum_stock_level?: number; low_stock_threshold?: number;
maximum_stock_level?: number; max_stock_level?: number;
reorder_point?: number; reorder_point?: number;
shelf_life_days?: number; shelf_life_days?: number;
requires_refrigeration?: boolean; requires_refrigeration?: boolean;
requires_freezing?: boolean; requires_freezing?: boolean;
is_seasonal?: boolean; is_seasonal?: boolean;
supplier_id?: string; supplier_id?: string;
cost_per_unit?: number; average_cost?: number;
notes?: string; notes?: string;
} }
@@ -44,15 +44,15 @@ export interface IngredientResponse {
description?: string; description?: string;
category: string; category: string;
unit_of_measure: string; unit_of_measure: string;
minimum_stock_level: number; low_stock_threshold: number;
maximum_stock_level: number; max_stock_level: number;
reorder_point: number; reorder_point: number;
shelf_life_days?: number; shelf_life_days?: number;
requires_refrigeration: boolean; requires_refrigeration: boolean;
requires_freezing: boolean; requires_freezing: boolean;
is_seasonal: boolean; is_seasonal: boolean;
supplier_id?: string; supplier_id?: string;
cost_per_unit?: number; average_cost?: number;
notes?: string; notes?: string;
current_stock_level: number; current_stock_level: number;
available_stock: 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 { useNavigate } from 'react-router-dom';
import { Button } from '../../ui/Button'; import { Button } from '../../ui/Button';
import { Card, CardHeader, CardBody } from '../../ui/Card';
import { useAuth } from '../../../contexts/AuthContext'; 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 { useTenantActions } from '../../../stores/tenant.store';
import { useTenantInitializer } from '../../../stores/useTenantInitializer'; import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import { import {
@@ -27,11 +28,13 @@ interface StepProps {
isLastStep: boolean; isLastStep: boolean;
} }
// Steps must match backend ONBOARDING_STEPS exactly
// Note: "user_registered" is auto-completed and not shown in UI
const STEPS: StepConfig[] = [ const STEPS: StepConfig[] = [
{ {
id: 'setup', id: 'setup',
title: 'Registrar Panadería', 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, component: RegisterTenantStep,
}, },
{ {
@@ -43,37 +46,136 @@ const STEPS: StepConfig[] = [
{ {
id: 'ml-training', id: 'ml-training',
title: 'Entrenamiento IA', title: 'Entrenamiento IA',
description: 'Entrena tu modelo de inteligencia artificial', description: 'Entrena tu modelo de inteligencia artificial personalizado',
component: MLTrainingStep, component: MLTrainingStep,
}, },
{ {
id: 'completion', id: 'completion',
title: 'Configuración Completa', 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, component: CompletionStep,
}, },
]; ];
export const OnboardingWizard: React.FC = () => { export const OnboardingWizard: React.FC = () => {
const [currentStepIndex, setCurrentStepIndex] = useState(0); const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [isInitialized, setIsInitialized] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
// Initialize tenant data for authenticated users // Initialize tenant data for authenticated users
useTenantInitializer(); useTenantInitializer();
// Get user progress from backend
const { data: userProgress, isLoading: isLoadingProgress, error: progressError } = useUserProgress(
user?.id || '',
{ enabled: !!user?.id }
);
const markStepCompleted = useMarkStepCompleted(); const markStepCompleted = useMarkStepCompleted();
const { setCurrentTenant } = useTenantActions(); 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 currentStep = STEPS[currentStepIndex];
const handlePrevious = () => {
if (currentStepIndex > 0) {
setCurrentStepIndex(currentStepIndex - 1);
}
};
const handleStepComplete = async (data?: any) => { 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 { try {
// Special handling for setup step - set the created tenant in tenant store // Special handling for setup step - set the created tenant in tenant store
if (currentStep.id === 'setup' && data?.tenant) { if (currentStep.id === 'setup' && data?.tenant) {
@@ -81,12 +183,15 @@ export const OnboardingWizard: React.FC = () => {
} }
// Mark step as completed in backend // Mark step as completed in backend
console.log(`📤 Sending API request to complete step: "${currentStep.id}"`);
await markStepCompleted.mutateAsync({ await markStepCompleted.mutateAsync({
userId: user?.id || '', userId: user.id,
stepName: currentStep.id, stepName: currentStep.id,
data data
}); });
console.log(`✅ Successfully completed step: "${currentStep.id}"`);
if (currentStep.id === 'completion') { if (currentStep.id === 'completion') {
navigate('/app'); navigate('/app');
} else { } else {
@@ -95,91 +200,235 @@ export const OnboardingWizard: React.FC = () => {
setCurrentStepIndex(currentStepIndex + 1); setCurrentStepIndex(currentStepIndex + 1);
} }
} }
} catch (error) { } catch (error: any) {
console.error('Error marking step as completed:', error); 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 StepComponent = currentStep.component;
const progressPercentage = userProgress?.completion_percentage || ((currentStepIndex + 1) / STEPS.length) * 100;
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto px-4 sm:px-6 space-y-4 sm:space-y-6 pb-6">
{/* Progress Bar */} {/* Enhanced Progress Header */}
<div className="mb-8"> <Card shadow="sm" padding="lg">
<div className="flex justify-between items-center mb-4"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 space-y-2 sm:space-y-0">
<h1 className="text-2xl font-bold text-[var(--text-primary)]"> <div className="text-center sm:text-left">
Bienvenido a Bakery IA <h1 className="text-xl sm:text-2xl font-bold text-[var(--text-primary)]">
</h1> Bienvenido a Bakery IA
<span className="text-sm text-[var(--text-secondary)]"> </h1>
Paso {currentStepIndex + 1} de {STEPS.length} <p className="text-[var(--text-secondary)] text-xs sm:text-sm mt-1">
</span> 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>
<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 <div
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300" 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: `${((currentStepIndex + 1) / STEPS.length) * 100}%` }} style={{ width: `${progressPercentage}%` }}
/> />
</div> </div>
<div className="flex justify-between mt-4"> {/* Mobile Step Indicators - Horizontal scroll on small screens */}
{STEPS.map((step, index) => ( <div className="sm:hidden">
<div <div className="flex space-x-4 overflow-x-auto pb-2 px-1">
key={step.id} {STEPS.map((step, index) => {
className={`flex-1 text-center px-2 ${ const isCompleted = userProgress?.steps.find(s => s.step_name === step.id)?.completed || index < currentStepIndex;
index <= currentStepIndex const isCurrent = index === currentStepIndex;
? 'text-[var(--color-primary)]'
: 'text-[var(--text-tertiary)]' return (
}`} <div
> key={step.id}
<div className={`text-xs font-medium mb-1`}> className={`flex-shrink-0 text-center min-w-[80px] ${
{step.title} isCompleted
</div> ? 'text-[var(--color-success)]'
<div className="text-xs opacity-75"> : isCurrent
{step.description} ? 'text-[var(--color-primary)]'
</div> : 'text-[var(--text-tertiary)]'
</div> }`}
))} >
<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>
</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 */} {/* Step Content */}
<div className="bg-[var(--bg-primary)] rounded-lg shadow-lg p-8"> <Card shadow="lg" padding="none">
<div className="mb-6"> <CardHeader padding="lg" divider>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-2"> <div className="flex items-center space-x-3">
{currentStep.title} <div className="w-8 h-8 sm:w-10 sm:h-10 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
</h2> <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">
<p className="text-[var(--text-secondary)]"> {currentStepIndex + 1}
{currentStep.description} </div>
</p> </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>
<StepComponent <CardBody padding="lg">
onNext={() => {}} // No-op - steps must use onComplete instead <StepComponent
onPrevious={handlePrevious} onNext={() => {}} // No-op - steps must use onComplete instead
onComplete={handleStepComplete} onPrevious={() => {}} // No-op - users cannot go back once they've moved forward
isFirstStep={currentStepIndex === 0} onComplete={handleStepComplete}
isLastStep={currentStepIndex === STEPS.length - 1} isFirstStep={currentStepIndex === 0}
/> isLastStep={currentStepIndex === STEPS.length - 1}
</div> />
</CardBody>
{/* Navigation */} </Card>
<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> </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 { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training'; import { useCreateTrainingJob, useTrainingWebSocket } from '../../../../api/hooks/training';
@@ -32,50 +32,59 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const createTrainingJob = useCreateTrainingJob(); const createTrainingJob = useCreateTrainingJob();
// WebSocket for real-time training progress // Memoized WebSocket callbacks to prevent reconnections
const trainingWebSocket = useTrainingWebSocket( 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 || '', currentTenant?.id || '',
jobId || '', jobId || '',
undefined, // token will be handled by the service undefined, // token will be handled by the service
{ jobId ? {
onProgress: (data) => { onProgress: handleProgress,
setTrainingProgress({ onCompleted: handleCompleted,
stage: 'training', onError: handleError,
progress: data.progress?.percentage || 0, onStarted: handleStarted
message: data.message || 'Entrenando modelo...', } : undefined
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...'
});
}
}
); );
const handleStartTraining = async () => { const handleStartTraining = async () => {
@@ -201,9 +210,16 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
<div className="flex justify-between text-xs text-[var(--text-tertiary)]"> <div className="flex justify-between text-xs text-[var(--text-tertiary)]">
<span>{trainingProgress.currentStep || 'Procesando...'}</span> <span>{trainingProgress.currentStep || 'Procesando...'}</span>
{trainingProgress.estimatedTimeRemaining && ( <div className="flex items-center gap-2">
<span>Tiempo estimado: {formatTime(trainingProgress.estimatedTimeRemaining)}</span> {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>
</div> </div>
)} )}
@@ -224,9 +240,9 @@ export const MLTrainingStep: React.FC<MLTrainingStepProps> = ({
</ul> </ul>
</div> </div>
{error && ( {(error || connectionError) && (
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4"> <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> </div>
)} )}

View File

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