Simplify the onboardinf flow components 2
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user